diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ccf235a12..14f4ad351 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -12,60 +12,320 @@ impl OpenCADStudio { .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); } - // ── TABLE cell editing ───────────────────────────────────────────── - // TABLE CELL — set text for a cell in the selected Table + // ── TABLE structure and cell editing ─────────────────────────────── cmd if cmd.starts_with("TABLE ") => { let rest = cmd.trim_start_matches("TABLE").trim(); - let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase(); - if sub_up == "CELL" { - let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); - // parts: ["CELL", "", "", ""] - let row_res = parts.get(1).and_then(|s| s.parse::().ok()); - let col_res = parts.get(2).and_then(|s| s.parse::().ok()); - let text = parts.get(3).copied().unwrap_or(""); - match (row_res, col_res) { - (Some(row), Some(col)) => { - let selected_handles: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let mut found = false; - for sh in &selected_handles { - if let Some(acadrust::EntityType::Table(tbl)) = self.tabs[i] - .scene - .document - .entities_mut() - .find(|e| e.common().handle == *sh) - { - if tbl.set_cell_text(row, col, text) { - found = true; - } - } - } - if found { - self.push_undo_snapshot(i, "TABLE CELL"); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "TABLE CELL: set [{row},{col}] = \"{text}\"." - ).as_ref()); - } else { - self.command_line.push_error( - crate::t!("TABLE CELL: select a Table entity first, or row/col out of range.").as_ref() - ); - } - } - _ => { - self.command_line - .push_info(crate::t!("Usage: TABLE CELL ").as_ref()); + #[derive(Clone)] + enum TableAction { + Cell(usize, usize, String), + InsertRow(usize), + DeleteRow(usize), + InsertColumn(usize), + DeleteColumn(usize), + Merge(usize, usize, usize, usize), + Unmerge(usize, usize), + RowHeight(usize, f64), + ColumnWidth(usize, f64), + Lock(usize, usize, bool), + Block(usize, usize, String), + Formula(usize, usize, String), + Field(usize, usize, acadrust::Handle), + } + let words: Vec<&str> = rest.split_whitespace().collect(); + let integer = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let real = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let action = match words.first().map(|word| word.to_ascii_uppercase()).as_deref() { + Some("CELL") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Cell( + row, + column, + parts.get(3).copied().unwrap_or("").to_string(), + )), + _ => None, } } - } else { + Some("INSERTROW") => integer(1).map(TableAction::InsertRow), + Some("DELETEROW") => integer(1).map(TableAction::DeleteRow), + Some("INSERTCOLUMN") | Some("INSERTCOL") => { + integer(1).map(TableAction::InsertColumn) + } + Some("DELETECOLUMN") | Some("DELETECOL") => { + integer(1).map(TableAction::DeleteColumn) + } + Some("MERGE") => match (integer(1), integer(2), integer(3), integer(4)) { + (Some(r1), Some(c1), Some(r2), Some(c2)) => { + Some(TableAction::Merge(r1, c1, r2, c2)) + } + _ => None, + }, + Some("UNMERGE") => match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Unmerge(row, column)), + _ => None, + }, + Some("ROWHEIGHT") => match (integer(1), real(2)) { + (Some(row), Some(height)) if height > 0.0 => { + Some(TableAction::RowHeight(row, height)) + } + _ => None, + }, + Some("COLUMNWIDTH") | Some("COLWIDTH") => match (integer(1), real(2)) { + (Some(column), Some(width)) if width > 0.0 => { + Some(TableAction::ColumnWidth(column, width)) + } + _ => None, + }, + Some("LOCK") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => Some(TableAction::Lock( + row, + column, + !value.eq_ignore_ascii_case("OFF"), + )), + _ => None, + }, + Some("BLOCK") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(name)) if !name.trim().is_empty() => { + Some(TableAction::Block(row, column, name.trim().to_string())) + } + _ => None, + } + } + Some("FORMULA") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(expression)) + if !expression.trim().is_empty() => + { + Some(TableAction::Formula( + row, + column, + expression.trim().to_string(), + )) + } + _ => None, + } + } + Some("FIELD") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => { + u64::from_str_radix(value.trim_start_matches("0x"), 16) + .ok() + .map(|handle| { + TableAction::Field( + row, + column, + acadrust::Handle::new(handle), + ) + }) + } + _ => None, + }, + _ => None, + }; + let Some(action) = action else { self.command_line.push_info( - "Usage: TABLE (creates new table) or TABLE CELL ", + "Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD", ); + return Some(Task::none()); + }; + let selected_handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if selected_handles.is_empty() { + self.command_line + .push_error("TABLE: select a table first."); + return Some(Task::none()); + } + let block_handle = if let TableAction::Block(_, _, name) = &action { + self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(name)) + .map(|record| record.handle) + } else { + None + }; + if matches!(action, TableAction::Block(_, _, _)) && block_handle.is_none() { + self.command_line.push_error("TABLE: block definition not found."); + return Some(Task::none()); + } + if let TableAction::Field(_, _, field_handle) = &action { + if !self.tabs[i].scene.document.fields.contains_key(field_handle) { + self.command_line.push_error("TABLE: field handle not found."); + return Some(Task::none()); + } + } + self.push_undo_snapshot(i, "TABLE EDIT"); + let mut changed = false; + for handle in &selected_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + else { + continue; + }; + match &action { + TableAction::Cell(row, column, text) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(text); + changed = true; + } + } + } + TableAction::InsertRow(row) if *row <= table.row_count() => { + let height = table + .rows + .get((*row).min(table.row_count().saturating_sub(1))) + .map(|row| row.height) + .unwrap_or(0.5); + table.insert_row(*row); + table.set_row_height(*row, height); + changed = true; + } + TableAction::DeleteRow(row) + if table.row_count() > 1 && *row < table.row_count() => + { + table.remove_row(*row); + changed = true; + } + TableAction::InsertColumn(column) if *column <= table.column_count() => { + let width = table + .columns + .get((*column).min(table.column_count().saturating_sub(1))) + .map(|column| column.width) + .unwrap_or(2.0); + table.insert_column(*column, width); + changed = true; + } + TableAction::DeleteColumn(column) + if table.column_count() > 1 && *column < table.column_count() => + { + table.remove_column(*column); + changed = true; + } + TableAction::Merge(r1, c1, r2, c2) + if *r1 < table.row_count() + && *r2 < table.row_count() + && *c1 < table.column_count() + && *c2 < table.column_count() => + { + table.merge_cells(acadrust::entities::table::CellRange::new( + (*r1).min(*r2), + (*c1).min(*c2), + (*r1).max(*r2), + (*c1).max(*c2), + )); + changed = true; + } + TableAction::Unmerge(row, column) => { + table.unmerge_cell(*row, *column); + changed = true; + } + TableAction::RowHeight(row, height) if *row < table.row_count() => { + table.set_row_height(*row, *height); + changed = true; + } + TableAction::ColumnWidth(column, width) + if *column < table.column_count() => + { + table.set_column_width(*column, *width); + changed = true; + } + TableAction::Lock(row, column, locked) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + cell.state.set(CellStateFlags::CONTENT_LOCKED, *locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, *locked); + changed = true; + } + } + TableAction::Block(row, column, _) => { + if let (Some(handle), Some(cell)) = + (block_handle, table.cell_mut(*row, *column)) + { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.contents.clear(); + cell.contents.push(CellContent::block(handle)); + cell.cell_type = CellType::Block; + changed = true; + } + } + } + TableAction::Formula(row, column, expression) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let formula = if expression.starts_with('=') { + expression.clone() + } else { + format!("={expression}") + }; + cell.set_text(&formula); + changed = true; + } + } + } + TableAction::Field(row, column, field_handle) => { + let mut attached = false; + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let mut content = CellContent::text(""); + content.field_handle = Some(*field_handle); + cell.contents.clear(); + cell.contents.push(content); + cell.cell_type = CellType::Text; + attached = true; + } + } + if attached { + if !table.field_handles.contains(field_handle) { + table.field_handles.push(*field_handle); + } + changed = true; + } + } + _ => {} + } + } + if changed { + self.invalidate_property_targets(i, &selected_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output("TABLE: edit applied."); + } else { + self.command_line + .push_error("TABLE: row/column is out of range or the cell is locked."); } } @@ -1161,8 +1421,7 @@ impl OpenCADStudio { return Some(Task::done(Message::AnnoObjectScaleOpen)); } - // DATALINK — import a CSV file into a table placed at the - // origin (one-time import; a live re-reading link is future work). + // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); @@ -1173,17 +1432,13 @@ impl OpenCADStudio { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { self.command_line.push_info( - "Usage: DATALINK — imports the CSV into a table at the origin.", + "Usage: DATALINK ", ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { - let rows_data: Vec> = text - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|line| line.split(',').map(|s| s.trim().to_string()).collect()) - .collect(); + let rows_data = parse_csv_table(&text); let nrows = rows_data.len(); let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); if nrows == 0 || ncols == 0 { @@ -1203,14 +1458,27 @@ impl OpenCADStudio { table.set_cell_text(r, c, cell); } } - self.push_undo_snapshot(i, "DATALINK"); - self.tabs[i] - .scene - .add_entity_clone(acadrust::EntityType::Table(table)); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." - ).as_ref()); + let doc = &self.tabs[i].scene.document; + let current_style = doc.header.current_table_style_name.clone(); + table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { + match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + } + }); + let link_path = std::fs::canonicalize(path) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::new( + table, &link_path, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } Err(e) => { self.command_line @@ -1219,6 +1487,151 @@ impl OpenCADStudio { } } + cmd if cmd == "DATALINKUPDATE" || cmd.starts_with("DATALINKUPDATE ") => { + let write_back = cmd + .trim_start_matches("DATALINKUPDATE") + .trim() + .eq_ignore_ascii_case("WRITE"); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, acadrust::EntityType::Table(_)).then_some(handle) + }) + .collect(); + let table_handles = if selected.is_empty() { + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Table(_)) + .then_some(entity.common().handle) + }) + .collect::>() + } else { + selected + }; + let mut jobs = Vec::new(); + for handle in &table_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + continue; + }; + let link_handle = table + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .find_map(|cell| cell.data_link_handle); + let Some(link_handle) = link_handle else { + continue; + }; + let path = match self.tabs[i].scene.document.objects.get(&link_handle) { + Some(acadrust::objects::ObjectType::ClassObject(object)) => { + match &object.data { + acadrust::objects::ClassObjectData::DataLink(link) => { + Some(link.connection_string.clone()) + } + _ => None, + } + } + _ => None, + }; + if let Some(path) = path { + jobs.push((*handle, link_handle, path)); + } + } + if jobs.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: no linked tables found."); + return Some(Task::none()); + } + if write_back { + let mut written = 0usize; + for (table_handle, _, path) in &jobs { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*table_handle) + else { + continue; + }; + let csv = table_to_csv(table); + if std::fs::write(path, csv).is_ok() { + written += 1; + } + } + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + ); + return Some(Task::none()); + } + let updates: Vec<_> = jobs + .into_iter() + .filter_map(|(table_handle, link_handle, path)| { + std::fs::read_to_string(&path) + .ok() + .map(|text| (table_handle, link_handle, parse_csv_table(&text))) + }) + .filter(|(_, _, rows)| !rows.is_empty()) + .collect(); + if updates.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: linked sources could not be read."); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "DATALINKUPDATE"); + use acadrust::entities::table::CellStateFlags; + let mut changed_handles = Vec::new(); + for (table_handle, link_handle, rows) in updates { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(table_handle) + else { + continue; + }; + while table.row_count() < rows.len() { + table.add_row(); + } + while table.row_count() > rows.len() { + table.remove_row(table.row_count() - 1); + } + while table.column_count() < columns { + let width = table.columns.last().map(|column| column.width).unwrap_or(2.0); + table.add_column(width); + } + while table.column_count() > columns { + table.remove_column(table.column_count() - 1); + } + for row in 0..rows.len() { + for column in 0..columns { + if let Some(cell) = table.cell_mut(row, column) { + cell.set_text( + rows[row].get(column).map(String::as_str).unwrap_or(""), + ); + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows.len() as i32; + cell.data_link_columns = columns as i32; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + } + changed_handles.push(table_handle); + } + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: updated {} linked table(s).", changed_handles.len()) + .as_ref(), + ); + } + // LANDXMLIMPORT — import survey points (LandXML // elements) as Point objects. Reads the coordinate text content // (northing easting elevation) → Point at (easting, northing, elev). @@ -1313,6 +1726,68 @@ fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { out } +fn parse_csv_table(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\n' if !quoted => { + if field.ends_with('\r') { + field.pop(); + } + row.push(std::mem::take(&mut field)); + if row.iter().any(|value| !value.is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + _ => field.push(ch), + } + } + if field.ends_with('\r') { + field.pop(); + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + if row.iter().any(|value| !value.is_empty()) { + rows.push(row); + } + } + rows +} + +fn table_to_csv(table: &acadrust::entities::Table) -> String { + fn escape(value: &str) -> String { + if value.contains([',', '"', '\r', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } + } + table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|cell| escape(cell.text_value())) + .collect::>() + .join(",") + }) + .collect::>() + .join("\r\n") +} + #[cfg(test)] mod tests { use crate::app::OpenCADStudio; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 63bc9f7ba..5e929c3e3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -390,6 +390,7 @@ inventory::submit!(crate::command::CommandRegistration { "OBJECTSCALE", // Import CSV into a table + LandXML survey points. "DATALINK", + "DATALINKUPDATE", "LANDXMLIMPORT", // Keyboard-shortcut (CUI) export / import. "CUIEXPORT", diff --git a/src/app/properties.rs b/src/app/properties.rs index a7a5088b3..9f81392ac 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -128,6 +128,9 @@ impl OpenCADStudio { crate::entities::spline::control_vertex_count(spline) }) } + acadrust::EntityType::Table(table) => { + Some(table.row_count().saturating_mul(table.column_count())) + } _ => None, }); vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) @@ -140,6 +143,7 @@ impl OpenCADStudio { false }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); + crate::entities::table::set_prop_current_cell(prop_vertex); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let new_panel = { @@ -1294,6 +1298,43 @@ impl OpenCADStudio { } } } + acadrust::EntityType::Table(table) => { + let mut names: Vec = doc + .objects + .values() + .filter_map(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) + if !style.name.trim().is_empty() => + { + Some(style.name.clone()) + } + _ => None, + }) + .collect(); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let selected = table + .table_style_handle + .and_then(|handle| doc.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style.name.clone()) + } + _ => None, + }) + .unwrap_or_else(|| "Standard".to_string()); + if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + names.insert(0, selected.clone()); + } + set_row_value( + &mut sections, + "tbl_style_handle", + crate::scene::model::object::PropValue::Choice { + selected, + options: names, + }, + ); + } _ => {} } @@ -1874,6 +1915,51 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, ) -> Option { let i = self.active_tab; + if let acadrust::EntityType::Table(table) = &mut entity { + const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; + if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { + use acadrust::entities::table::CellStateFlags; + use acadrust::objects::{ + ClassObject, ClassObjectData, DataLink, ObjectType, + }; + let document = &mut self.tabs[i].scene.document; + let link_handle = document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { + data_adapter: "CSV".to_string(), + description: path.clone(), + tooltip: path.clone(), + connection_string: path.clone(), + status_flags: 1, + update_status: "Linked".to_string(), + ..DataLink::default() + })); + object.handle = link_handle; + document + .objects + .insert(link_handle, ObjectType::ClassObject(object)); + let rows = table.row_count() as i32; + let columns = table.column_count() as i32; + for row in &mut table.rows { + for cell in &mut row.cells { + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows; + cell.data_link_columns = columns; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + table.name = std::path::Path::new(&path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Linked table") + .to_string(); + table.description = path; + } + } let tracks_draw_anchor = self.tabs[i].active_cmd.is_some() && matches!( &entity, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83d007583..2a74327b0 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2364,7 +2364,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } self.push_undo_snapshot(i, "CHPROP"); - if field == "block" { + if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(val.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } + } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames // the definition they share. Commit closes the list. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 679b3ad9a..c37894f11 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4677,6 +4677,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Table(table)) => { + table.row_count().saturating_mul(table.column_count()) + } _ => 0, } } else { @@ -4688,6 +4691,7 @@ impl OpenCADStudio { let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; + crate::entities::table::set_prop_current_cell(next); self.refresh_properties(); } Task::none() @@ -4864,6 +4868,49 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "tbl_cell_content_color" | "tbl_cell_background_color" + ) { + let cell_index = self.tabs[i].properties.prop_vertex; + if !handles.is_empty() { + self.push_undo_snapshot(i, "TABLE CELL COLOR"); + for &handle in &handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + let columns = table.column_count(); + if columns == 0 { + continue; + } + if let Some(cell) = table.cell_mut( + cell_index / columns, + cell_index % columns, + ) { + let style = cell.style.get_or_insert_with(Default::default); + if field == "tbl_cell_content_color" { + style.content_color = color; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::CONTENT_COLOR, + ); + } else { + style.background_color = color; + style.fill_enabled = true; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + } + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].properties.open_color_field = None; + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } if !handles.is_empty() { self.push_undo_snapshot(i, "CHPROP"); let idx = if field == "gradient_color_2" { 1 } else { 0 }; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 379eddbf4..899a3139b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4391,6 +4391,95 @@ 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 flows_up = table + .table_style_handle + .and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + }) + .is_some_and(|object| { + matches!( + object, + acadrust::objects::ObjectType::TableStyle(style) + if matches!( + style.flow_direction, + acadrust::objects::TableFlowDirection::Up + ) + ) + }); + 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)?; + Some(( + 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(); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } // 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. diff --git a/src/entities/table.rs b/src/entities/table.rs index 67acd76b9..c967d0d6f 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -13,6 +13,18 @@ use crate::scene::view::transform; use crate::scene::model::wire_model::SnapHint; use crate::t; +thread_local! { + static PROPERTY_CELL: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub fn set_prop_current_cell(index: usize) { + PROPERTY_CELL.with(|cell| cell.set(index)); +} + +fn prop_current_cell() -> usize { + PROPERTY_CELL.with(std::cell::Cell::get) +} + fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -227,6 +239,71 @@ fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { } } +fn table_cell_reference(reference: &str) -> Option<(usize, usize)> { + let reference = reference.trim(); + let split = reference + .char_indices() + .find(|(_, ch)| ch.is_ascii_digit())? + .0; + let (letters, digits) = reference.split_at(split); + if letters.is_empty() || digits.is_empty() { + return None; + } + let mut column = 0usize; + for ch in letters.chars() { + if !ch.is_ascii_alphabetic() { + return None; + } + column = column + .checked_mul(26)? + .checked_add(ch.to_ascii_uppercase() as usize - 'A' as usize + 1)?; + } + let row = digits.parse::().ok()?.checked_sub(1)?; + Some((row, column.checked_sub(1)?)) +} + +fn evaluate_table_formula(table: &Table, expression: &str) -> Option { + let body = expression.trim().strip_prefix('=')?.trim(); + if let Some((row, column)) = table_cell_reference(body) { + return table.cell(row, column).map(|cell| cell.text_value().to_string()); + } + let open = body.find('(')?; + let close = body.rfind(')')?; + let function = body[..open].trim().to_ascii_uppercase(); + let range = &body[open + 1..close]; + let (first, last) = range.split_once(':').unwrap_or((range, range)); + let (r1, c1) = table_cell_reference(first)?; + let (r2, c2) = table_cell_reference(last)?; + let mut values = Vec::new(); + for row in r1.min(r2)..=r1.max(r2) { + for column in c1.min(c2)..=c1.max(c2) { + if let Some(value) = table + .cell(row, column) + .and_then(|cell| cell.text_value().trim().parse::().ok()) + { + values.push(value); + } + } + } + match function.as_str() { + "SUM" => Some(values.iter().sum::().to_string()), + "AVERAGE" | "AVG" if !values.is_empty() => { + Some((values.iter().sum::() / values.len() as f64).to_string()) + } + "COUNT" => Some(values.len().to_string()), + "MIN" if !values.is_empty() => { + Some(values.into_iter().fold(f64::INFINITY, f64::min).to_string()) + } + "MAX" if !values.is_empty() => Some( + values + .into_iter() + .fold(f64::NEG_INFINITY, f64::max) + .to_string(), + ), + _ => None, + } +} + fn fallback_content_centers( bounds: [f32; 4], sizes: &[(f32, f32)], @@ -312,12 +389,12 @@ fn fallback_content_centers( fn content_display_value( document: &acadrust::CadDocument, - table_handle: acadrust::Handle, + table: &Table, content: &acadrust::entities::table::CellContent, ) -> String { if let Some(handle) = content.field_handle { if let Some(value) = - crate::entities::field::resolve_handle(document, handle, table_handle) + crate::entities::field::resolve_handle(document, handle, table.common.handle) { return value; } @@ -331,7 +408,8 @@ fn content_display_value( } } } - format_cell_value(&content.value) + let value = format_cell_value(&content.value); + evaluate_table_formula(table, &value).unwrap_or(value) } fn resolved_content_geometry( @@ -1326,8 +1404,7 @@ pub fn tessellate_table( .iter() .enumerate() .filter_map(|(index, content)| { - let text = - content_display_value(document, tab.common.handle, content); + let text = content_display_value(document, tab, content); (!text.is_empty()).then_some((index, content, text)) }) .collect(); @@ -1740,30 +1817,120 @@ pub fn tessellate_table( impl Grippable for Table { fn grips(&self) -> Vec { - vec![square_grip( + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + let width = self.total_width(); + let height = self.total_height(); + let mut grips = vec![square_grip( 0, - glam::DVec3::new( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - )] + origin, + )]; + grips.push(square_grip(1, origin + horizontal * width)); + grips.push(square_grip(2, origin + down * height)); + let mut offset = 0.0; + for (column, definition) in self.columns.iter().enumerate() { + offset += definition.width; + if column + 1 < self.columns.len() { + grips.push(square_grip( + 100 + column, + origin + horizontal * offset + down * (height * 0.5), + )); + } + } + offset = 0.0; + for (row, definition) in self.rows.iter().enumerate() { + offset += definition.height; + if row + 1 < self.rows.len() { + grips.push(square_grip( + 1000 + row, + origin + horizontal * (width * 0.5) + down * offset, + )); + } + } + for (index, data) in self.break_data.iter().enumerate() { + grips.push(square_grip( + 2000 + index, + origin + glam::DVec3::new(data.position.x, data.position.y, data.position.z), + )); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if let GripApply::Translate(delta) = apply { + if let Some(grip) = self.grips().into_iter().find(|grip| grip.id == grip_id) { + self.apply_grip(grip_id, GripApply::Absolute(grip.world + delta)); + } + return; + } + let GripApply::Absolute(point) = apply else { + return; + }; if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; + self.insertion_point.x = point.x; + self.insertion_point.y = point.y; + self.insertion_point.z = point.z; + return; + } + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if grip_id == 1 { + let width = (point - origin).dot(horizontal).max(1.0e-6); + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= width / current; } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; + } + } else if grip_id == 2 { + let height = (point - origin).dot(down).max(1.0e-6); + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= height / current; } } + } else if (100..1000).contains(&grip_id) { + let column = grip_id - 100; + let before: f64 = self.columns.iter().take(column).map(|item| item.width).sum(); + if let Some(definition) = self.columns.get_mut(column) { + definition.width = ((point - origin).dot(horizontal) - before).max(1.0e-6); + } + } else if (1000..2000).contains(&grip_id) { + let row = grip_id - 1000; + let before: f64 = self.rows.iter().take(row).map(|item| item.height).sum(); + if let Some(definition) = self.rows.get_mut(row) { + definition.height = ((point - origin).dot(down) - before).max(1.0e-6); + } + } else if grip_id >= 2000 { + if let Some(data) = self.break_data.get_mut(grip_id - 2000) { + let offset = point - origin; + data.position = acadrust::types::Vector3::new(offset.x, offset.y, offset.z); + } } } } @@ -1772,13 +1939,6 @@ impl PropertyEditable for Table { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { use crate::entities::common::edit_prop as edit; use acadrust::entities::table::BreakOptionFlags; - - let fmt_h = |oh: &Option| -> String { - match oh { - Some(h) if !h.is_null() => format!("{:X}", h.value()), - _ => "(none)".to_string(), - } - }; let toggle = |label: &str, field: &'static str, b: bool| -> Property { Property { label: label.into(), @@ -1789,26 +1949,38 @@ impl PropertyEditable for Table { // Direction = angle of the horizontal direction vector in the XY plane. let direction_deg = (self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees(); - let mut content_count = 0usize; - let mut block_content_count = 0usize; - let mut field_content_count = 0usize; - let mut linked_cell_count = 0usize; - for row in &self.rows { - for cell in &row.cells { - content_count += cell.contents.len(); - linked_cell_count += usize::from(cell.has_linked_data); - for content in &cell.contents { - block_content_count += usize::from(content.block_handle.is_some()); - field_content_count += usize::from(content.field_handle.is_some()); - } - } - } - let break_heights = self - .break_data - .iter() - .map(|data| format!("{:.4}", data.height)) - .collect::>() - .join(", "); + let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0); + let cell_count = self.rows.len().saturating_mul(self.columns.len()); + let cell_index = prop_current_cell().min(cell_count.saturating_sub(1)); + let cell_row = if self.columns.is_empty() { + 0 + } else { + cell_index / self.columns.len() + }; + let cell_column = if self.columns.is_empty() { + 0 + } else { + cell_index % self.columns.len() + }; + let cell = self.cell(cell_row, cell_column); + let cell_style = cell.and_then(|cell| cell.style.as_ref()); + let alignment = match cell_style.map(|style| style.alignment).unwrap_or(5) { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + let cell_locked = cell.is_some_and(|cell| { + use acadrust::entities::table::CellStateFlags; + cell.state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ) + }); vec![ PropSection { @@ -1817,42 +1989,125 @@ impl PropertyEditable for Table { ro( t!("Table style").as_ref(), "tbl_style_handle", - fmt_h(&self.table_style_handle), + "Standard", ), - ro(t!("Rows").as_ref(), "tbl_rows", self.rows.len().to_string()), - ro(t!("Columns").as_ref(), "tbl_cols", self.columns.len().to_string()), - ro(t!("Contents").as_ref(), "tbl_contents", content_count.to_string()), + edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), + edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), + edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), + edit(t!("Direction").as_ref(), "tbl_direction", direction_deg), + edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64), + edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64), + edit(t!("Table width").as_ref(), "tbl_width", self.total_width()), + edit(t!("Table height").as_ref(), "tbl_height", self.total_height()), + ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)), + ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)), + ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)), + ], + }, + PropSection { + title: t!("Cell").into_owned(), + props: vec![ + Property { + label: t!("Current cell").into_owned(), + field: "tbl_current_cell", + value: PropValue::Stepper { + field: "tbl_current_cell", + display: format!( + "{},{} ({} / {})", + cell_row, + cell_column, + cell_index.saturating_add(1), + cell_count + ), + }, + }, + ro(t!("Row").as_ref(), "tbl_cell_row", cell_row.to_string()), ro( - t!("Block contents").as_ref(), - "tbl_block_contents", - block_content_count.to_string(), + t!("Column").as_ref(), + "tbl_cell_column", + cell_column.to_string(), ), - ro( - t!("Field contents").as_ref(), - "tbl_field_contents", - field_content_count.to_string(), + Property { + label: t!("Contents").into_owned(), + field: "tbl_cell_text", + value: PropValue::PlainText( + cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), + ), + }, + Property { + label: t!("Alignment").into_owned(), + field: "tbl_cell_alignment", + value: PropValue::Choice { + selected: alignment.to_string(), + options: vec![ + "Top Left".into(), + "Top Center".into(), + "Top Right".into(), + "Middle Left".into(), + "Middle Center".into(), + "Middle Right".into(), + "Bottom Left".into(), + "Bottom Center".into(), + "Bottom Right".into(), + ], + }, + }, + edit( + t!("Text height").as_ref(), + "tbl_cell_text_height", + cell_style.map(|style| style.text_height).unwrap_or(0.18), ), - ro( - t!("Merged ranges").as_ref(), - "tbl_merged_ranges", - self.merged_ranges.len().to_string(), + Property { + label: t!("Content color").into_owned(), + field: "tbl_cell_content_color", + value: PropValue::ColorChoice( + cell_style + .map(|style| style.content_color) + .unwrap_or(acadrust::types::Color::ByBlock), + ), + }, + Property { + label: t!("Background color").into_owned(), + field: "tbl_cell_background_color", + value: PropValue::ColorChoice( + cell_style + .map(|style| style.background_color) + .unwrap_or(acadrust::types::Color::ByBlock), + ), + }, + Property { + label: t!("Data format").into_owned(), + field: "tbl_cell_format", + value: PropValue::PlainText( + cell_style.map(|style| style.value_format.clone()).unwrap_or_default(), + ), + }, + toggle( + t!("Background fill").as_ref(), + "tbl_cell_fill", + cell_style.is_some_and(|style| style.fill_enabled), ), - ro( - t!("Linked cells").as_ref(), - "tbl_linked_cells", - linked_cell_count.to_string(), + toggle( + t!("Top border").as_ref(), + "tbl_cell_border_top", + cell_style.is_none_or(|style| !style.top_border.invisible), ), - ro(t!("Direction").as_ref(), "tbl_direction", format!("{:.4}", direction_deg)), - ro( - t!("Table width").as_ref(), - "tbl_width", - format!("{:.4}", self.total_width()), + toggle( + t!("Right border").as_ref(), + "tbl_cell_border_right", + cell_style.is_none_or(|style| !style.right_border.invisible), ), - ro( - t!("Table height").as_ref(), - "tbl_height", - format!("{:.4}", self.total_height()), + toggle( + t!("Bottom border").as_ref(), + "tbl_cell_border_bottom", + cell_style.is_none_or(|style| !style.bottom_border.invisible), ), + toggle( + t!("Left border").as_ref(), + "tbl_cell_border_left", + cell_style.is_none_or(|style| !style.left_border.invisible), + ), + toggle(t!("Locked").as_ref(), "tbl_cell_locked", cell_locked), ], }, PropSection { @@ -1863,11 +2118,19 @@ impl PropertyEditable for Table { "tbl_break_enabled", self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS), ), - ro( - t!("Direction").as_ref(), - "tbl_break_direction", - format!("{:?}", self.break_flow_direction), - ), + Property { + label: t!("Direction").into_owned(), + field: "tbl_break_direction", + value: PropValue::Choice { + selected: match self.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Right => "Right", + acadrust::entities::table::BreakFlowDirection::Left => "Left", + acadrust::entities::table::BreakFlowDirection::Vertical => "Down", + } + .to_string(), + options: vec!["Right".into(), "Left".into(), "Down".into()], + }, + }, toggle( t!("Repeat top labels").as_ref(), "tbl_break_repeat_top", @@ -1892,12 +2155,7 @@ impl PropertyEditable for Table { self.break_options .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS), ), - ro( - t!("Segments").as_ref(), - "tbl_break_segments", - self.break_ranges.len().max(self.break_data.len()).to_string(), - ), - ro(t!("Break heights").as_ref(), "tbl_break_height", break_heights), + edit(t!("Maximum height").as_ref(), "tbl_break_height", break_height), edit(t!("Spacing").as_ref(), "tbl_break_spacing", self.break_spacing), ], }, @@ -1924,11 +2182,198 @@ impl PropertyEditable for Table { self.break_options.set(flag, on); return; } - if field == "tbl_break_spacing" { - if let Some(v) = parse_f64(value) { - self.break_spacing = v; + let cell_index = prop_current_cell(); + let columns = self.columns.len(); + let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns)); + if let Some((row, column)) = cell_position { + match field { + "tbl_cell_text" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(value); + } + } + return; + } + "tbl_cell_alignment" => { + let alignment = match value.trim().to_ascii_uppercase().as_str() { + "TOP LEFT" => 1, + "TOP CENTER" => 2, + "TOP RIGHT" => 3, + "MIDDLE LEFT" => 4, + "MIDDLE RIGHT" => 6, + "BOTTOM LEFT" => 7, + "BOTTOM CENTER" => 8, + "BOTTOM RIGHT" => 9, + _ => 5, + }; + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.alignment = alignment; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::ALIGNMENT, + ); + } + return; + } + "tbl_cell_format" => { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.value_format = value.to_string(); + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::DATA_FORMAT, + ); + } + return; + } + "tbl_cell_fill" => { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.fill_enabled = if value == "toggle" { + !style.fill_enabled + } else { + value == "true" + }; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + return; + } + "tbl_cell_border_top" + | "tbl_cell_border_right" + | "tbl_cell_border_bottom" + | "tbl_cell_border_left" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::{ + BorderPropertyFlags, CellEdgeFlags, + }; + let style = cell.style.get_or_insert_with(Default::default); + let (border, edge) = match field { + "tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP), + "tbl_cell_border_right" => { + (&mut style.right_border, CellEdgeFlags::RIGHT) + } + "tbl_cell_border_bottom" => { + (&mut style.bottom_border, CellEdgeFlags::BOTTOM) + } + _ => (&mut style.left_border, CellEdgeFlags::LEFT), + }; + let visible = if value == "toggle" { + border.invisible + } else { + value == "true" + }; + border.invisible = !visible; + border.override_flags.insert(BorderPropertyFlags::INVISIBILITY); + style.applied_border_edges.insert(edge); + } + return; + } + "tbl_cell_locked" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::CellStateFlags; + let locked = if value == "toggle" { + !cell.state.contains(CellStateFlags::CONTENT_LOCKED) + } else { + value == "true" + }; + cell.state.set(CellStateFlags::CONTENT_LOCKED, locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, locked); + } + return; + } + _ => {} } } + let Some(number) = parse_f64(value) else { + if field == "tbl_break_direction" { + self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() { + "LEFT" => acadrust::entities::table::BreakFlowDirection::Left, + "DOWN" | "VERTICAL" => { + acadrust::entities::table::BreakFlowDirection::Vertical + } + _ => acadrust::entities::table::BreakFlowDirection::Right, + }; + } + return; + }; + match field { + "tbl_insert_x" => self.insertion_point.x = number, + "tbl_insert_y" => self.insertion_point.y = number, + "tbl_insert_z" => self.insertion_point.z = number, + "tbl_direction" => { + let radians = number.to_radians(); + self.horizontal_direction.x = radians.cos(); + self.horizontal_direction.y = radians.sin(); + self.horizontal_direction.z = 0.0; + } + "tbl_rows" => { + let requested = number.round().max(1.0) as usize; + while self.rows.len() < requested { + self.add_row(); + } + while self.rows.len() > requested { + self.remove_row(self.rows.len() - 1); + } + } + "tbl_cols" => { + let requested = number.round().max(1.0) as usize; + let width = self.columns.last().map(|column| column.width).unwrap_or(2.0); + while self.columns.len() < requested { + self.add_column(width); + } + while self.columns.len() > requested { + self.remove_column(self.columns.len() - 1); + } + } + "tbl_width" if number > 0.0 => { + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= number / current; + } + } + } + "tbl_height" if number > 0.0 => { + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= number / current; + } + } + } + "tbl_cell_text_height" if number > 0.0 => { + if let Some((row, column)) = cell_position { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.text_height = number; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::TEXT_HEIGHT, + ); + } + } + } + "tbl_break_height" if number >= 0.0 => { + if self.break_data.is_empty() { + self.break_data.push(acadrust::entities::table::TableBreakData { + position: acadrust::types::Vector3::ZERO, + height: number, + flags: 0, + }); + } else { + for data in &mut self.break_data { + data.height = number; + } + } + } + "tbl_break_spacing" if number >= 0.0 => self.break_spacing = number, + _ => {} + } } } diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index a2e20e5b6..e3e319b17 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -1,6 +1,11 @@ -// DATALINK command — manages data links to external spreadsheets. +// DATALINK command — manages persistent links to external tabular data. +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use acadrust::entities::Table; +use acadrust::EntityType; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_link.svg")); @@ -12,3 +17,112 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("DATALINK".to_string()), } } + +pub struct DataLinkPlaceCommand { + table: Table, + plane: WorkingPlane, +} + +impl DataLinkPlaceCommand { + pub fn new(mut table: Table, path: &str) -> Self { + table.name = format!("__OPENCAD_LINK_PENDING__{path}"); + Self { + table, + plane: WorkingPlane::default(), + } + } + + fn preview(&self, point: DVec3) -> WireModel { + let point = self.plane.to_local(point); + let mut points = Vec::new(); + let mut x = 0.0; + for column in 0..=self.table.column_count() { + if column > 0 { + x += self.table.columns[column - 1].width; + } + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -self.table.total_height(), 0.0)) + .as_vec3() + .to_array(), + ); + } + let mut y = 0.0; + for row in 0..=self.table.row_count() { + if row > 0 { + y -= self.table.rows[row - 1].height; + } + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(self.table.total_width(), y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "data_link_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } +} + +impl CadCommand for DataLinkPlaceCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn name(&self) -> &'static str { + "DATALINK" + } + + fn prompt(&self) -> String { + "DATALINK Specify insertion point:".to_string() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let point = self.plane.to_local(point); + self.table.insertion_point = acadrust::types::Vector3::new(point.x, point.y, point.z); + CmdResult::CommitAndExit( + self.plane + .place_entity(EntityType::Table(self.table.clone())), + ) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + Some(self.preview(point)) + } +} diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index c243e2b60..1e9a15195 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -1,16 +1,14 @@ -// TABLE command — create an empty table entity. +// TABLE command — create a styled empty table. // -// Workflow: -// 1. Text: Enter number of columns (default 3) -// 2. Text: Enter number of rows (default 4, includes header row) -// 3. Point: Click insertion point -// -// Creates a Table entity with uniform row height (0.5) and column width (2.0). +// Title/header rows come from the selected table style and are not counted as +// data rows. The command remembers its previous settings and supports either a +// fixed insertion point or a two-corner sizing window. use acadrust::entities::TableBuilder; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; +use std::sync::{Mutex, OnceLock}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,22 +27,80 @@ pub fn tool() -> ToolDef { } const DEFAULT_COLS: usize = 3; -const DEFAULT_ROWS: usize = 4; -const COL_WIDTH: f64 = 2.0; -const ROW_HEIGHT: f64 = 0.5; +const DEFAULT_DATA_ROWS: usize = 4; +const DEFAULT_COL_WIDTH: f64 = 2.0; +const DEFAULT_ROW_HEIGHT: f64 = 0.5; +#[derive(Clone, Copy)] +enum InsertionMode { + Point, + Window, +} + +#[derive(Clone, Copy)] +struct TableDefaults { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + insertion_mode: InsertionMode, +} + +impl Default for TableDefaults { + fn default() -> Self { + Self { + columns: DEFAULT_COLS, + data_rows: DEFAULT_DATA_ROWS, + column_width: DEFAULT_COL_WIDTH, + row_height: DEFAULT_ROW_HEIGHT, + insertion_mode: InsertionMode::Point, + } + } +} + +fn saved_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(TableDefaults::default())) +} + +#[derive(Clone, Copy)] enum Step { Columns, - Rows { cols: usize }, - Insertion { cols: usize, rows: usize }, + DataRows { columns: usize }, + ColumnWidth { columns: usize, data_rows: usize }, + RowHeight { + columns: usize, + data_rows: usize, + column_width: f64, + }, + InsertionMode { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + Insertion { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + WindowFirst { columns: usize, data_rows: usize }, + WindowSecond { + columns: usize, + data_rows: usize, + first: DVec3, + }, } pub struct TableCommand { step: Step, style_handle: Option, - column_width: f64, - row_height: f64, + suggested_column_width: f64, + suggested_row_height: f64, preview_scale: f64, + title_row: bool, + header_row: bool, plane: WorkingPlane, } @@ -53,9 +109,11 @@ impl TableCommand { Self { step: Step::Columns, style_handle: None, - column_width: COL_WIDTH, - row_height: ROW_HEIGHT, + suggested_column_width: DEFAULT_COL_WIDTH, + suggested_row_height: DEFAULT_ROW_HEIGHT, preview_scale: 1.0, + title_row: true, + header_row: true, plane: WorkingPlane::default(), } } @@ -71,21 +129,120 @@ impl TableCommand { .max(style.header_row_style.text_height) .max(style.title_row_style.text_height) .max(1.0e-6); - let row_height = text_height * 1.5 + style.vertical_margin * 2.0; - let column_width = text_height * 8.0 + style.horizontal_margin * 2.0; Self { step: Step::Columns, style_handle: Some(style_handle), - column_width, - row_height, + suggested_column_width: text_height * 8.0 + style.horizontal_margin * 2.0, + suggested_row_height: text_height * 1.5 + style.vertical_margin * 2.0, preview_scale: if style.annotative { annotation_multiplier } else { 1.0 }, + title_row: !style.title_suppressed, + header_row: !style.header_suppressed, plane: WorkingPlane::default(), } } + + fn defaults(&self) -> TableDefaults { + let mut defaults = saved_defaults().lock().map(|value| *value).unwrap_or_default(); + if (defaults.column_width - DEFAULT_COL_WIDTH).abs() < 1.0e-9 { + defaults.column_width = self.suggested_column_width; + } + if (defaults.row_height - DEFAULT_ROW_HEIGHT).abs() < 1.0e-9 { + defaults.row_height = self.suggested_row_height; + } + defaults + } + + fn total_rows(&self, data_rows: usize) -> usize { + data_rows + usize::from(self.title_row) + usize::from(self.header_row) + } + + fn build_table( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> EntityType { + let point = self.plane.to_local(point); + let mut table = TableBuilder::new(rows, columns) + .at(Vector3::new(point.x, point.y, point.z)) + .row_height(row_height) + .column_width(column_width) + .build(); + table.table_style_handle = self.style_handle; + self.plane.place_entity(EntityType::Table(table)) + } + + fn preview_grid( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> WireModel { + let point = self.plane.to_local(point); + let width = columns as f64 * column_width * self.preview_scale; + let height = rows as f64 * row_height * self.preview_scale; + let mut points = Vec::with_capacity((rows + columns + 2) * 2); + for column in 0..=columns { + let x = column as f64 * column_width * self.preview_scale; + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -height, 0.0)) + .as_vec3() + .to_array(), + ); + } + for row in 0..=rows { + let y = -(row as f64 * row_height * self.preview_scale); + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(width, y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "table_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } } impl CadCommand for TableCommand { @@ -98,146 +255,278 @@ impl CadCommand for TableCommand { } fn prompt(&self) -> String { + let defaults = self.defaults(); match &self.step { Step::Columns => t!( "TABLE Enter number of columns [%{cols}]:", - cols = DEFAULT_COLS + cols = defaults.columns ) .into_owned(), - Step::Rows { cols } => t!( - "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):", - rows = DEFAULT_ROWS, - cols = cols + Step::DataRows { columns } => t!( + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):", + rows = defaults.data_rows, + cols = columns ) .into_owned(), - Step::Insertion { cols, rows } => t!( + Step::ColumnWidth { .. } => { + format!("TABLE Enter column width [{:.4}]:", defaults.column_width) + } + Step::RowHeight { .. } => { + format!("TABLE Enter row height [{:.4}]:", defaults.row_height) + } + Step::InsertionMode { .. } => format!( + "TABLE Insertion behavior [Point/Window] <{}>:", + match defaults.insertion_mode { + InsertionMode::Point => "Point", + InsertionMode::Window => "Window", + } + ), + Step::Insertion { columns, data_rows, .. } => t!( "TABLE Specify insertion point [%{cols}×%{rows}]:", - cols = cols, - rows = rows + cols = columns, + rows = self.total_rows(*data_rows) ) .into_owned(), + Step::WindowFirst { .. } => "TABLE Specify first corner:".to_string(), + Step::WindowSecond { .. } => "TABLE Specify opposite corner:".to_string(), } } fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) + matches!( + self.step, + Step::Columns + | Step::DataRows { .. } + | Step::ColumnWidth { .. } + | Step::RowHeight { .. } + | Step::InsertionMode { .. } + ) } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); - match &self.step { + let input = text.trim(); + let defaults = self.defaults(); + match self.step { Step::Columns => { - let cols = if t.is_empty() { - DEFAULT_COLS + let columns = if input.is_empty() { + defaults.columns } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Rows { cols }; - Some(CmdResult::NeedPoint) + self.step = Step::DataRows { columns }; } - Step::Rows { cols } => { - let cols = *cols; - let rows = if t.is_empty() { - DEFAULT_ROWS + Step::DataRows { columns } => { + let data_rows = if input.is_empty() { + defaults.data_rows } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Insertion { cols, rows }; - Some(CmdResult::NeedPoint) + self.step = Step::ColumnWidth { columns, data_rows }; + } + Step::ColumnWidth { columns, data_rows } => { + let column_width = if input.is_empty() { + defaults.column_width + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::RowHeight { + columns, + data_rows, + column_width, + }; + } + Step::RowHeight { columns, data_rows, column_width } => { + let row_height = if input.is_empty() { + defaults.row_height + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::InsertionMode { + columns, + data_rows, + column_width, + row_height, + }; + } + Step::InsertionMode { columns, data_rows, column_width, row_height } => { + let mode = if input.is_empty() { + defaults.insertion_mode + } else if "POINT".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Point + } else if "WINDOW".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Window + } else { + return None; + }; + if let Ok(mut saved) = saved_defaults().lock() { + *saved = TableDefaults { + columns, + data_rows, + column_width, + row_height, + insertion_mode: mode, + }; + } + self.step = match mode { + InsertionMode::Point => Step::Insertion { + columns, + data_rows, + column_width, + row_height, + }, + InsertionMode::Window => Step::WindowFirst { columns, data_rows }, + }; + } + Step::Insertion { .. } | Step::WindowFirst { .. } | Step::WindowSecond { .. } => { + return None; } - Step::Insertion { .. } => None, } + Some(CmdResult::NeedPoint) } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::Columns => { - // Accept default. - self.step = Step::Rows { cols: DEFAULT_COLS }; - CmdResult::NeedPoint - } - Step::Rows { cols } => { - let cols = *cols; - self.step = Step::Insertion { - cols, - rows: DEFAULT_ROWS, + self.on_text_input("") + .map_or(CmdResult::Cancel, |_| CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => CmdResult::CommitAndExit(self.build_table( + point, + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowFirst { columns, data_rows } => { + self.step = Step::WindowSecond { + columns, + data_rows, + first: point, }; CmdResult::NeedPoint } - Step::Insertion { .. } => CmdResult::Cancel, + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + CmdResult::CommitAndExit(self.build_table( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => CmdResult::NeedPoint, } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { cols, rows } = self.step { - let point = self.plane.to_local(pt); - let ins = Vector3::new(point.x, point.y, point.z); - let mut table = TableBuilder::new(rows, cols) - .at(ins) - .row_height(self.row_height) - .column_width(self.column_width) - .build(); - table.table_style_handle = self.style_handle; - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Table(table))) - } else { - CmdResult::NeedPoint - } - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if let Step::Insertion { cols, rows } = self.step { - // Preview: outline of the table bounding box. - let w = (cols as f32) * (self.column_width * self.preview_scale) as f32; - let h = (rows as f32) * (self.row_height * self.preview_scale) as f32; - let point = self.plane.to_local(pt); - let corners = [ + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => Some(self.preview_grid( point, - point + DVec3::X * w as f64, - point + DVec3::new(w as f64, -(h as f64), 0.0), - point - DVec3::Y * h as f64, - ] - .map(|corner| self.plane.to_world(corner).as_vec3().to_array()); - let [p0, p1, p2, p3] = corners; - Some(WireModel { - point_marker: None, - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "table_preview".into(), - points: vec![ - p0, p1, p1, p2, p2, p3, p3, p0, - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) - } else { - None + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + Some(self.preview_grid( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => None, } } } +inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); // TableCommand +pub struct TableCellEditCommand { + handle: acadrust::Handle, + table: acadrust::entities::Table, + row: usize, + column: usize, +} + +impl TableCellEditCommand { + pub fn new( + handle: acadrust::Handle, + table: &acadrust::entities::Table, + row: usize, + column: usize, + ) -> Self { + Self { + handle, + table: table.clone(), + row, + column, + } + } +} + +impl CadCommand for TableCellEditCommand { + fn name(&self) -> &'static str { + "TABLE CELL" + } + + fn prompt(&self) -> String { + let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + format!( + "TABLE CELL Enter text for [{},{}] <{}>:", + self.row, self.column, current + ) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + use acadrust::entities::table::CellStateFlags; + let cell = self.table.cell_mut(self.row, self.column)?; + if cell + .state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + { + return Some(CmdResult::Cancel); + } + cell.set_text(text); + Some(CmdResult::ReplaceMany( + vec![(self.handle, vec![EntityType::Table(self.table.clone())])], + Vec::new(), + )) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +}