forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/sheet-set-manager
# Conflicts: # Cargo.lock # src/app/control/actions.rs # src/ui/window/field_dialog.rs
This commit is contained in:
commit
1696316e1f
95 files changed
+4269
-722
No files matched your search
@@ -1268,7 +1268,8 @@ impl OpenCADStudio {
|
||||
})
|
||||
.collect();
|
||||
// Pick-first (#422): with offsettable objects already selected,
|
||||
// skip the pick step and go straight to distance / side.
|
||||
// skip the distance and pick steps and open on the live side
|
||||
// preview.
|
||||
let preselected: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
|
||||
+118
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand};
|
||||
use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand};
|
||||
use crate::ui::window::field_dialog::{
|
||||
fields_of, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS,
|
||||
NAMED_TYPES,
|
||||
@@ -35,13 +35,70 @@ impl OpenCADStudio {
|
||||
}
|
||||
Some(Task::none())
|
||||
}
|
||||
cmd if cmd == "_FIELD_CELL" || cmd.starts_with("_FIELD_CELL ") => {
|
||||
let parts: Vec<&str> = cmd.split_whitespace().skip(1).collect();
|
||||
if let Some(expression) = self.table_cell_expression(i, &parts) {
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
// The reference appends at the end, with no operator.
|
||||
state.formula.push_str(&expression);
|
||||
}
|
||||
self.refresh_field_preview();
|
||||
// A real result gets the Decimal format, as the reference picks it.
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
if state.formula_format == 0 && state.preview.contains('.') {
|
||||
state.formula_format = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.field_dialog.is_some() {
|
||||
self.active_modal = Some(crate::app::ModalKind::Field);
|
||||
self.refresh_field_preview();
|
||||
}
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula text for a picked table cell or cell range:
|
||||
/// `Table(%<\_ObjId H>%).B4` or `Table(%<\_ObjId H>%).Evaluate(Sum(A3:B5))`.
|
||||
fn table_cell_expression(&self, i: usize, parts: &[&str]) -> Option<String> {
|
||||
let function = *parts.first()?;
|
||||
let cell = |at: &[&str]| -> Option<(codec::Handle, usize, usize)> {
|
||||
let handle = codec::Handle::new(u64::from_str_radix(at.first()?, 16).ok()?);
|
||||
let point = glam::DVec3::new(at.get(1)?.parse().ok()?, at.get(2)?.parse().ok()?, at.get(3)?.parse().ok()?);
|
||||
let document = &self.tabs[i].scene.document;
|
||||
let codec::EntityType::Table(table) = document.get_entity(handle)? else {
|
||||
return None;
|
||||
};
|
||||
let hit = crate::modules::annotate::table_cmd::table_cell_at(table, None, point)?;
|
||||
Some((handle, hit.row, hit.column))
|
||||
};
|
||||
let name = |row: usize, column: usize| format!("{}{}", column_letters(column), row + 1);
|
||||
let (table, row, column) = cell(parts.get(1..5)?)?;
|
||||
if function == "Cell" {
|
||||
return Some(format!("Table(%<\\_ObjId {:X}>%).{}", table.value(), name(row, column)));
|
||||
}
|
||||
let (second_table, row2, column2) = cell(parts.get(5..9)?)?;
|
||||
if second_table != table {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"Table(%<\\_ObjId {:X}>%).Evaluate({function}({}:{}))",
|
||||
table.value(),
|
||||
name(row.min(row2), column.min(column2)),
|
||||
name(row.max(row2), column.max(column2)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Open the Field dialog for `target`.
|
||||
pub(in crate::app) fn open_field_dialog(&mut self, target: FieldTarget) {
|
||||
let mut state = FieldDialogState::new(target);
|
||||
if !matches!(target, FieldTarget::NewText) {
|
||||
state.placeholder_block = self.tabs[self.active_tab]
|
||||
.active_block_edit_session()
|
||||
.map(|session| session.block_name.clone());
|
||||
}
|
||||
state.examples = DATE_FORMATS
|
||||
.iter()
|
||||
.map(|f| self.field_value(&format!("\\AcVar Date \\f \"{f}\""), &[]))
|
||||
@@ -69,7 +126,17 @@ impl OpenCADStudio {
|
||||
return;
|
||||
};
|
||||
let (code, objects) = state.code();
|
||||
let preview = if state.complete() { self.field_value(&code, &objects) } else { String::new() };
|
||||
let preview = if state.complete() {
|
||||
if state.name == "BlockPlaceholder" {
|
||||
// A placeholder shows its property name until a block reference resolves it.
|
||||
let (label, _, _) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property];
|
||||
text_case(label, state.text_case)
|
||||
} else {
|
||||
self.field_value(&code, &objects)
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
state.preview = preview;
|
||||
}
|
||||
@@ -170,6 +237,18 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
FieldDialogMsg::PlotScale(k) => state.plot_scale = k,
|
||||
FieldDialogMsg::PlaceholderProperty(k) => {
|
||||
state.placeholder_property = k;
|
||||
state.text_case = 0;
|
||||
}
|
||||
FieldDialogMsg::TableFunction(function) => {
|
||||
// The dialog waits while the cells are picked.
|
||||
self.active_modal = None;
|
||||
let command = FieldTablePickCommand::new(function);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
return Task::none();
|
||||
}
|
||||
FieldDialogMsg::SelectObject => {
|
||||
// The dialog waits while one object is picked.
|
||||
self.active_modal = None;
|
||||
@@ -241,3 +320,40 @@ impl OpenCADStudio {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Spreadsheet column letters: A … Z, AA, AB …
|
||||
fn column_letters(mut column: usize) -> String {
|
||||
let mut letters = Vec::new();
|
||||
loop {
|
||||
letters.push(b'A' + (column % 26) as u8);
|
||||
if column < 26 {
|
||||
break;
|
||||
}
|
||||
column = column / 26 - 1;
|
||||
}
|
||||
letters.reverse();
|
||||
String::from_utf8(letters).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The Field dialog's text cases: (none), Uppercase, Lowercase, First capital, Title case.
|
||||
fn text_case(value: &str, case: usize) -> String {
|
||||
match case {
|
||||
1 => value.to_uppercase(),
|
||||
2 => value.to_lowercase(),
|
||||
3 => {
|
||||
let lower = value.to_lowercase();
|
||||
let mut chars = lower.chars();
|
||||
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
|
||||
}
|
||||
4 => value
|
||||
.split(' ')
|
||||
.map(|word| {
|
||||
let lower = word.to_lowercase();
|
||||
let mut chars = lower.chars();
|
||||
chars.next().map(|c| c.to_uppercase().chain(chars).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
+71
-2
@@ -93,7 +93,26 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
||||
self.dispatch_command_inner(cmd, false)
|
||||
let task = self.dispatch_command_inner(cmd, false);
|
||||
self.clear_idle_tool_highlight();
|
||||
task
|
||||
}
|
||||
|
||||
/// Turn the ribbon highlight off when the command just dispatched left
|
||||
/// nothing running: no interactive command, dialog or navigation mode.
|
||||
/// One-shot commands (view changes, clipboard, toggles, audits…) would
|
||||
/// otherwise leave it lit forever; interactive commands and dialog owners
|
||||
/// keep theirs until the command end / modal close clears it. (#355)
|
||||
pub(in crate::app) fn clear_idle_tool_highlight(&mut self) {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none()
|
||||
&& self.active_modal.is_none()
|
||||
&& !self.tabs[i].pan_mode
|
||||
&& !self.tabs[i].orbit_mode
|
||||
&& !self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a verb typed at the interactive command line, falling back to
|
||||
@@ -105,7 +124,9 @@ impl OpenCADStudio {
|
||||
/// callers (ribbon, plugins, headless automation) use `dispatch_command`
|
||||
/// and never get silent substitution.
|
||||
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
|
||||
self.dispatch_command_inner(cmd, true)
|
||||
let task = self.dispatch_command_inner(cmd, true);
|
||||
self.clear_idle_tool_highlight();
|
||||
task
|
||||
}
|
||||
|
||||
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
|
||||
@@ -206,6 +227,13 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Light the ribbon button of the command now starting, however it was
|
||||
// started (typed, alias, shortcut, Repeat), so the user sees where it
|
||||
// lives. A transparent command returned above and leaves the running
|
||||
// command's highlight alone.
|
||||
self.ribbon
|
||||
.show_command(strip_command_prefixes(cmd).unwrap_or(cmd));
|
||||
|
||||
if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
|
||||
// try_dispatch returns true for both finished commands and interactive
|
||||
// commands that it just installed. If no command is now active, the
|
||||
@@ -1084,3 +1112,44 @@ fn strip_command_prefixes(cmd: &str) -> Option<&str> {
|
||||
let bare = cmd.trim_start_matches(['_', '.']);
|
||||
(bare.len() != cmd.len() && !bare.is_empty()).then_some(bare)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ribbon_highlight_tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
fn fresh() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_alias_lights_the_button_of_the_command_it_starts() {
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("O");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_some());
|
||||
assert_eq!(app.ribbon.active_tool(), Some("OFFSET"));
|
||||
let _ = app.dispatch_command_or_suggest("L");
|
||||
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparent_command_keeps_the_running_command_lit() {
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("LINE");
|
||||
let _ = app.dispatch_command_or_suggest("'ZOOM E");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_some());
|
||||
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_shot_command_leaves_nothing_lit() {
|
||||
// REGEN cancels the running LINE and finishes at once, so neither
|
||||
// its own button nor LINE's may stay lit.
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("LINE");
|
||||
let _ = app.dispatch_command_or_suggest("REGEN");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_none());
|
||||
assert_eq!(app.ribbon.active_tool(), None);
|
||||
}
|
||||
}
|
||||
@@ -1086,6 +1086,7 @@ impl OpenCADStudio {
|
||||
| "CONSTRAINTNAMEFORMAT"
|
||||
| "DYNCONSTRAINTDISPLAY"
|
||||
| "CCONSTRAINTFORM"
|
||||
| "DYNMODE"
|
||||
) =>
|
||||
{
|
||||
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
|
||||
@@ -1110,7 +1111,7 @@ impl OpenCADStudio {
|
||||
self.command_line.push_info(&super::plotvars::setvar_listing());
|
||||
self.command_line.push_info(&super::blockvars::setvar_listing());
|
||||
self.command_line.push_info(
|
||||
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY DYNMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
);
|
||||
} else {
|
||||
// Point cloud settings, kept with the drawing.
|
||||
@@ -1703,6 +1704,17 @@ impl OpenCADStudio {
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
|
||||
},
|
||||
"DYNMODE" => match &value {
|
||||
Some(v) => match v.parse::<i16>() {
|
||||
Ok(mode) if (-3..=3).contains(&mode) => {
|
||||
self.dyn_mode = mode;
|
||||
self.dyn_input = mode > 0;
|
||||
Ok((format!("DYNMODE = {mode}"), true))
|
||||
}
|
||||
_ => Err("SETVAR: integer from -3 to 3 required.".into()),
|
||||
},
|
||||
None => Ok((format!("DYNMODE = {}", self.dyn_mode), false)),
|
||||
},
|
||||
"ZOOMWHEEL" => match &value {
|
||||
Some(v) => match parse_bool(v) {
|
||||
Some(reversed) => {
|
||||
@@ -2685,6 +2697,7 @@ impl OpenCADStudio {
|
||||
// Without this it marked the drawing
|
||||
// modified instead of persisting.
|
||||
| "TEXTFILL"
|
||||
| "DYNMODE"
|
||||
) {
|
||||
self.persist_settings_if_changed();
|
||||
} else {
|
||||
@@ -3782,6 +3795,51 @@ mod tests {
|
||||
let _ = app.run_command_line("SETVAR DELOBJ 4");
|
||||
assert_eq!(app.delete_objects, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynmode_direct_and_setvar_toggles_dynamic_input() {
|
||||
let mut app = fresh_app();
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 3);
|
||||
|
||||
// Turn off via direct command: DYNMODE 0
|
||||
let _ = app.run_command_line("DYNMODE 0");
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 0);
|
||||
|
||||
// Turn on via direct command: DYNMODE 3
|
||||
let _ = app.run_command_line("DYNMODE 3");
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 3);
|
||||
|
||||
// Turn off via SETVAR: SETVAR DYNMODE 0
|
||||
let _ = app.run_command_line("SETVAR DYNMODE 0");
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 0);
|
||||
|
||||
// Turn on via SETVAR: SETVAR DYNMODE 1 (pointer input only)
|
||||
let _ = app.run_command_line("SETVAR DYNMODE 1");
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
|
||||
// Out of range rejected
|
||||
let _ = app.run_command_line("DYNMODE 5");
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
assert!(app.dyn_input);
|
||||
|
||||
let _ = app.run_command_line("DYNMODE -5");
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
assert!(app.dyn_input);
|
||||
|
||||
// F12 turns it off temporarily (negative) and restores the mode.
|
||||
let _ = app.update(crate::app::Message::ToggleDynInput);
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, -1);
|
||||
|
||||
let _ = app.update(crate::app::Message::ToggleDynInput);
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in new issue
Block a user