forked from 1b-agent/opencadstudio-upstream
Switching to a Layout re-tessellated the whole model — `memo_miss=186455`, 2034 ms — to arrive at geometry identical to what was already in the memo. The only thing that differed was colour. `local_wires_for` already knows this: it stores the raw colour and drops its background argument with `let _ = bg_color;`, noting that `Batches::finalize` applies `adapt_to_bg` last, "so the cache no longer has to rebuild on BACKGROUND / layout-switch". The tessellation memo's guard had not been told, and still mixed the background in, so every entry was discarded on a switch. `finalize` now records what its colour resolution consumed — the raw colour, the raw glyph colours, and the three flags it branches on — as `WireModel::bg_adapt`, and `resolve_colors_for_bg` replays it for another background. The guard drops the background, so a memo entry serves any layout. The resolution recomputes from the recorded raw colour rather than from the resolved one, which makes it idempotent and therefore safe over a set that mixes memo hits with freshly tessellated misses. Re-adapting the resolved colour would not be safe: `adapt_to_bg` snaps a near-white colour to pure black on a light background, and pure black comes back pure *white* on a dark one, losing the tint. A test pins that asymmetry so the raw colour is not optimised away later. The contract is tested against the real `finalize` rather than a copy of its logic: for every background pair, building under one and resolving for the other lands exactly where building under the other would have. `bg_adapt` is `None` for wires built anywhere else, and the resolution skips them, so nothing outside the block cache changes colour. It is also `None` when the resolution would be a no-op — a preserved colour with no canvas fill looks the same under every background — which keeps the common case at no cost. Ordering is unchanged: the pass runs inside the builder, before `append_scene_markers` and `apply_refedit_fade`, both of which have their own separate background dependency. `wires-build` gains `colors=` so the pass has a number of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
826 lines
26 KiB
Rust
826 lines
26 KiB
Rust
// TABLE command — create a styled empty table.
|
||
//
|
||
// 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, InputKind, WorkingPlane};
|
||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||
use crate::scene::model::wire_model::WireModel;
|
||
use crate::t;
|
||
|
||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/table.svg"));
|
||
|
||
pub fn tool() -> ToolDef {
|
||
ToolDef {
|
||
id: "TABLE",
|
||
label: "Table",
|
||
icon: ICON,
|
||
event: ModuleEvent::Command("TABLE".to_string()),
|
||
}
|
||
}
|
||
|
||
const DEFAULT_COLS: usize = 3;
|
||
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<TableDefaults> {
|
||
static DEFAULTS: OnceLock<Mutex<TableDefaults>> = OnceLock::new();
|
||
DEFAULTS.get_or_init(|| Mutex::new(TableDefaults::default()))
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Step {
|
||
Columns,
|
||
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<acadrust::Handle>,
|
||
suggested_column_width: f64,
|
||
suggested_row_height: f64,
|
||
preview_scale: f64,
|
||
title_row: bool,
|
||
header_row: bool,
|
||
plane: WorkingPlane,
|
||
}
|
||
|
||
impl TableCommand {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
step: Step::Columns,
|
||
style_handle: None,
|
||
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(),
|
||
}
|
||
}
|
||
|
||
pub fn with_style(
|
||
style_handle: acadrust::Handle,
|
||
style: &acadrust::objects::TableStyle,
|
||
annotation_multiplier: f64,
|
||
) -> Self {
|
||
let text_height = style
|
||
.data_row_style
|
||
.text_height
|
||
.max(style.header_row_style.text_height)
|
||
.max(style.title_row_style.text_height)
|
||
.max(1.0e-6);
|
||
Self {
|
||
step: Step::Columns,
|
||
style_handle: Some(style_handle),
|
||
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,
|
||
scale: f64,
|
||
) -> WireModel {
|
||
let point = self.plane.to_local(point);
|
||
let width = columns as f64 * column_width * scale;
|
||
let height = rows as f64 * row_height * scale;
|
||
let mut points = Vec::with_capacity((rows + columns + 2) * 2);
|
||
for column in 0..=columns {
|
||
let x = column as f64 * column_width * 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 * 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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: 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 {
|
||
fn set_working_plane(&mut self, plane: WorkingPlane) {
|
||
self.plane = plane;
|
||
}
|
||
|
||
fn name(&self) -> &'static str {
|
||
"TABLE"
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
let defaults = self.defaults();
|
||
match &self.step {
|
||
Step::Columns => t!(
|
||
"TABLE Enter number of columns [%{cols}]:",
|
||
cols = defaults.columns
|
||
)
|
||
.into_owned(),
|
||
Step::DataRows { columns } => t!(
|
||
"TABLE Enter number of data rows [%{rows}] (%{cols} cols):",
|
||
rows = defaults.data_rows,
|
||
cols = columns
|
||
)
|
||
.into_owned(),
|
||
Step::ColumnWidth { .. } => t!(
|
||
"TABLE Enter column width [%{width}]:",
|
||
width = format!("{:.4}", defaults.column_width)
|
||
)
|
||
.into_owned(),
|
||
Step::RowHeight { .. } => t!(
|
||
"TABLE Enter row height [%{height}]:",
|
||
height = format!("{:.4}", defaults.row_height)
|
||
)
|
||
.into_owned(),
|
||
Step::InsertionMode { .. } => t!(
|
||
"TABLE Insertion behavior [Point/Window] <%{mode}>:",
|
||
mode = match defaults.insertion_mode {
|
||
InsertionMode::Point => t!("Point"),
|
||
InsertionMode::Window => t!("Window"),
|
||
}
|
||
)
|
||
.into_owned(),
|
||
Step::Insertion { columns, data_rows, .. } => t!(
|
||
"TABLE Specify insertion point [%{cols}×%{rows}]:",
|
||
cols = columns,
|
||
rows = self.total_rows(*data_rows)
|
||
)
|
||
.into_owned(),
|
||
Step::WindowFirst { .. } => {
|
||
t!("%{n} Specify first corner:", n = "TABLE").into_owned()
|
||
}
|
||
Step::WindowSecond { .. } => {
|
||
t!("%{n} Specify opposite corner:", n = "TABLE").into_owned()
|
||
}
|
||
}
|
||
}
|
||
|
||
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> {
|
||
let input = text.trim();
|
||
let defaults = self.defaults();
|
||
match self.step {
|
||
Step::Columns => {
|
||
let columns = if input.is_empty() {
|
||
defaults.columns
|
||
} else {
|
||
input.parse::<usize>().ok().filter(|value| *value > 0)?
|
||
};
|
||
self.step = Step::DataRows { columns };
|
||
}
|
||
Step::DataRows { columns } => {
|
||
let data_rows = if input.is_empty() {
|
||
defaults.data_rows
|
||
} else {
|
||
input.parse::<usize>().ok().filter(|value| *value > 0)?
|
||
};
|
||
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::<f64>().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::<f64>().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;
|
||
}
|
||
}
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
|
||
fn on_enter(&mut self) -> CmdResult {
|
||
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::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_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
|
||
match self.step {
|
||
Step::Insertion {
|
||
columns,
|
||
data_rows,
|
||
column_width,
|
||
row_height,
|
||
} => Some(self.preview_grid(
|
||
point,
|
||
self.total_rows(data_rows),
|
||
columns,
|
||
column_width,
|
||
row_height,
|
||
self.preview_scale,
|
||
)),
|
||
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,
|
||
1.0,
|
||
))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
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("")
|
||
.replace("\\P", "/n");
|
||
t!(
|
||
"TABLE CELL Enter text for [%{row},%{column}] <%{current}>:",
|
||
row = self.row,
|
||
column = self.column,
|
||
current = current
|
||
)
|
||
.into_owned()
|
||
}
|
||
|
||
// 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 {
|
||
CmdResult::NeedPoint
|
||
}
|
||
|
||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||
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);
|
||
}
|
||
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())])],
|
||
Vec::new(),
|
||
))
|
||
}
|
||
|
||
// 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"),
|
||
}
|
||
}
|
||
}
|