diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8413bc782..bd57247d4 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -745,6 +745,7 @@ impl OpenCADStudio { // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. if was_active && self.tabs[i].active_cmd.is_none() { self.tabs[i].scene.set_hover_highlight(None); + self.command_line.set_step_options(Vec::new()); self.restore_add_selected_defaults(); } task @@ -763,6 +764,7 @@ impl OpenCADStudio { .and_then(|c| c.attedit_pending_handle()); if let Some(ins_handle) = attedit_handle { self.tabs[i].active_cmd = None; + self.command_line.set_step_options(Vec::new()); self.open_attribute_editor(ins_handle); return Task::none(); } @@ -770,6 +772,12 @@ impl OpenCADStudio { if let Some(p) = prompt { self.command_line.push_info(&p); } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); if !self.tabs[i] .active_cmd .as_ref() @@ -1234,6 +1242,10 @@ impl OpenCADStudio { // and ribbon dropdowns now, not on the next reopen (#407). self.refresh_layer_panel(); } + let insert_block_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; // Record where this draw ended so ARC_CONT can continue from it // (before `entity` is moved into commit_entity). self.update_cont_anchor(&entity); @@ -1265,6 +1277,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); + if let Some(name) = insert_block_name { + self.record_block_insert(&name); + } if let Some(pd) = pending { self.commit_undo_delta(i, pd); } @@ -1926,6 +1941,10 @@ impl OpenCADStudio { .as_mut() .and_then(|c| c.attreq_take_insert()); if let Some(entity) = entity { + let insert_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; let label = self.history_label_from_active_cmd(i, "INSERT"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); @@ -1934,6 +1953,9 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + if let Some(n) = insert_name { + self.record_block_insert(&n); + } self.restore_pre_cmd_tangent(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 6cf2394ae..caeb9f05c 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -330,8 +330,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::insert_block::InsertBlockCommand; - let cmd = InsertBlockCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = InsertBlockCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } @@ -343,8 +351,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::minsert::MinsertCommand; - let cmd = MinsertCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = MinsertCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 5e929c3e3..44a33084b 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -533,6 +533,7 @@ inventory::submit!(crate::command::CommandRegistration { "SKETCHINC", "SKPOLY", "SKTOLERANCE", + "CLIPROMPTLINES", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 043aff806..c1d30748d 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -879,6 +879,7 @@ impl OpenCADStudio { | "CURSORTYPE" | "SNAPANG" | "TEXTFILL" + | "CLIPROMPTLINES" | "ATTREQ" | "ATTDIA" | "DIMASSOC" @@ -967,7 +968,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1313,6 +1314,20 @@ impl OpenCADStudio { false, )), }, + "CLIPROMPTLINES" => match &value { + Some(v) => match v.parse::() { + Ok(n) if (0..=50).contains(&n) => { + self.cliprompt_lines = n; + self.command_line.set_cliprompt_lines(n as u8); + Ok((format!("CLIPROMPTLINES = {n}"), true)) + } + _ => Err("SETVAR: integer from 0 to 50 required.".into()), + }, + None => Ok(( + format!("CLIPROMPTLINES = {}", self.cliprompt_lines), + false, + )), + }, "ATTREQ" => match &value { Some(v) => parse_bool(v) .map(|b| { @@ -1889,6 +1904,7 @@ impl OpenCADStudio { | "PICKBOX" | "CURSORTYPE" | "SNAPANG" + | "CLIPROMPTLINES" ) { self.persist_settings_if_changed(); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 518d32946..d98a3958f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -477,6 +477,18 @@ pub(super) struct OpenCADStudio { /// The `a` channel is always 1.0. default_bg_color: Option<[f32; 4]>, default_paper_bg_color: Option<[f32; 4]>, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + cliprompt_lines: i32, + /// MRU list of block names inserted via INSERT, most recent first, capped to 20. + block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count), capped. + block_freq: std::collections::HashMap, + /// Last time block-usage was flushed to disk (debounce per 2.4). + #[cfg(not(target_arch = "wasm32"))] + block_usage_last_persist: Option, + #[cfg(target_arch = "wasm32")] + block_usage_last_persist: Option<()>, /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, @@ -3156,6 +3168,10 @@ impl OpenCADStudio { savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), + block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, ucs_icon_hover: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 4e4ad329c..8b65c73e5 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -230,6 +230,33 @@ pub struct UserSettings { /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + #[serde(default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines")] + pub cliprompt_lines: i32, + /// Most-recently-inserted block names, most recent first, capped to 20. + /// Used to rank INSERT suggestions without touching the drawing file. + #[serde(default)] + pub block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count). + #[serde(default)] + pub block_freq: std::collections::HashMap, +} + +fn default_clipromptlines() -> i32 { + 3 +} + +fn deserialize_clipromptlines<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = i32::deserialize(de).unwrap_or(3); + Ok(v.clamp(0, 50)) +} + +pub fn clamp_clipromptlines(v: i32) -> i32 { + v.clamp(0, 50) } impl Default for UserSettings { @@ -268,6 +295,9 @@ impl Default for UserSettings { bg_color: None, paper_bg_color: None, language: crate::i18n::Language::default(), + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), } } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d03ff0828..d6c48ab69 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -202,6 +202,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Command-line entry is shown uppercase. self.command_line.input.push_str(&s.to_uppercase()); self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT (see CommandInput) + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } } } self.command_line.autocomplete_cursor = None; @@ -231,6 +241,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.input.pop(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT + { + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } + } self.focus_cmd_input() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 53162afdd..3092b6d90 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -349,6 +349,9 @@ impl OpenCADStudio { bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), language: self.language, + cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), + block_mru: self.block_mru.clone(), + block_freq: self.block_freq.clone(), } } @@ -407,6 +410,17 @@ impl OpenCADStudio { if crate::i18n::set_language(s.language).is_ok() { self.language = s.language; } + self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines); + self.command_line + .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); + // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) + self.block_mru = s.block_mru.iter().take(20).cloned().collect(); + self.block_freq = s + .block_freq + .iter() + .take(200) + .map(|(k, v)| (k.clone(), *v)) + .collect(); // Push the restored background onto every drawing tab that exists now // (the start tab and any initial drawing). Tabs created later pick it // up via `apply_bg_default` at their construction site. @@ -416,6 +430,79 @@ impl OpenCADStudio { self.rebuild_ribbon_modules(); } + pub(crate) fn record_block_insert(&mut self, name: &str) { + let key = name.to_ascii_uppercase(); + *self.block_freq.entry(key).or_insert(0) += 1; + self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name)); + self.block_mru.insert(0, name.to_string()); + if self.block_mru.len() > 20 { + self.block_mru.truncate(20); + } + // Cap freq map to 200 most frequent to bound persistence size. + if self.block_freq.len() > 200 { + let mut items: Vec<(String, u32)> = self + .block_freq + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + items.sort_by(|a, b| b.1.cmp(&a.1)); + items.truncate(200); + self.block_freq = items.into_iter().collect(); + } + // Debounce disk writes: at most once per second (2.4) to avoid thrash + // during rapid scripting/batch inserts. Immediate first write ensures + // crash recovery. Wasm has no `std::time::Instant` guarantee, so persist + // immediately there. + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + let should_persist = match self.block_usage_last_persist { + None => true, + Some(t) => now.duration_since(t).as_secs_f32() >= 1.0, + }; + if should_persist { + self.block_usage_last_persist = Some(now); + self.persist_settings_if_changed(); + } + } + #[cfg(target_arch = "wasm32")] + { + self.persist_settings_if_changed(); + } + } + + pub(crate) fn block_usage_snapshot(&self) -> rustc_hash::FxHashMap { + let mut out = rustc_hash::FxHashMap::default(); + for (idx, name) in self.block_mru.iter().enumerate() { + let up = name.to_ascii_uppercase(); + let freq = self.block_freq.get(&up).copied().unwrap_or(0); + out.insert(up, (freq, idx)); + } + // Ensure every freq entry has at least an entry (for blocks not in MRU) + for (up, freq) in &self.block_freq { + out.entry(up.clone()).or_insert((*freq, usize::MAX)); + } + out + } + + pub(crate) fn ranked_block_names(&self, names: &[String]) -> Vec { + let mut scored: Vec<(&String, u32, usize)> = names + .iter() + .map(|n| { + let up = n.to_ascii_uppercase(); + let f = self.block_freq.get(&up).copied().unwrap_or(0); + let m = self + .block_mru + .iter() + .position(|x| x.eq_ignore_ascii_case(n)) + .unwrap_or(usize::MAX); + (n, f, m) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0))); + scored.into_iter().map(|(n, _, _)| n.clone()).collect() + } + /// Adopt the per-drawing sysvars stored in tab `i`'s document header — /// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into /// the live app state, so each drawing keeps its own. Called when a drawing diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6fbd26db7..67f37af0c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1455,9 +1455,38 @@ impl OpenCADStudio { self.command_line.input = s; return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); } - self.command_line.input = s; + let live_input = s.clone(); + self.command_line.input = live_input.clone(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT: update picker on each keystroke + // without requiring Enter. Performance-first: uses upper/lower caches + // and partial sort (O(k)) so per-keystroke is <0.2ms even for 10k blocks. + // Suggestion count is in direct relation to CLIPROMPTLINES (picker limit). + { + let i = self.active_tab; + // Capture prompt/options while holding cmd borrow, then release before + // borrowing command_line to satisfy borrow checker. + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live_input) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } + } else { + (false, Vec::new(), String::new()) + }; + if should_update { + self.command_line.set_step_options(opts); + // Update pinned prompt text live without pushing new history entry + // (avoids flooding history with one entry per keystroke). + if let Some(last) = self.command_line.history.last_mut() { + if last.pinned { + last.text = prompt; + } + } + } + } Task::batch(vec![sweep, Task::none()]) } diff --git a/src/command.rs b/src/command.rs index 9dcbdc20c..46cad3434 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1753,6 +1753,16 @@ pub trait CadCommand: Send { Vec::new() } + /// Live search: called on each keystroke in the command line while the + /// command is active. Return true if the input updated internal filter + /// and the UI should refresh (prompt/options). Used for INSERT/MINSERT + /// incremental block name search without requiring Enter. Performance + /// critical — implementations must use precomputed caches and partial + /// sorting. + fn on_live_input(&mut self, _input: &str) -> bool { + false + } + /// Push the active coordinate frame in full precision. Geometry commands /// use it for plane-local construction; inquiry and modify commands use it /// for local deltas, angles and transformation axes. diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 86d39bab7..e54671c98 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -38,7 +38,7 @@ enum AwaitKind { } pub struct InsertBlockCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Uniform X/Y scale applied to the placed block (default 1). x_scale: f64, @@ -57,9 +57,17 @@ pub struct InsertBlockCommand { } impl InsertBlockCommand { - pub fn new(available: Vec) -> Self { + /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). + /// `cliprompt_lines` directly controls suggestion count (relation per spec). + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, x_scale: 1.0, y_scale: 1.0, @@ -76,8 +84,14 @@ impl InsertBlockCommand { /// from `base`) rubber-band under the cursor. Used by paste-as-block, which /// has just defined the block and only needs the drop point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { + // Minimal picker for the locked-name path; not used for Name step. + let picker = crate::modules::insert::picker::BlockPicker::new( + vec![name.clone()], + rustc_hash::FxHashMap::default(), + 0, + ); Self { - available: vec![name.clone()], + picker, step: Step::Point { name }, x_scale: 1.0, y_scale: 1.0, @@ -88,6 +102,7 @@ impl InsertBlockCommand { plane: WorkingPlane::default(), } } + } impl CadCommand for InsertBlockCommand { @@ -102,12 +117,39 @@ impl CadCommand for InsertBlockCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("INSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!( + "INSERT No matching blocks for \"%{needle}\"", + needle = needle + ) + .into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("INSERT Enter block name:").into_owned() + } else { + t!( + "INSERT Enter block name: [%{shown} of %{total} — type to search]", + shown = shown, + total = total + ) + .into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("INSERT Enter block name:%{hint}", hint = hint).into_owned() + t!( + "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", + needle = needle, + shown = filtered.len() + ) + .into_owned() + } } Step::Point { name } => match self.awaiting { Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), @@ -165,6 +207,32 @@ impl CadCommand for InsertBlockCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self + .picker + .filtered() + .iter() + .map(|n| crate::command::CmdOption::new(n, n)) + .collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + // Performance: only recompute if needle actually changed; picker + // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn on_enter(&mut self) -> CmdResult { match &self.step { // A bare Enter while a scale/rotation value is awaited keeps the @@ -204,12 +272,25 @@ impl CadCommand for InsertBlockCommand { match &self.step { Step::Name => { let name = text.trim(); - if !self.available.iter().any(|c| c.eq_ignore_ascii_case(name)) { - return None; + // Empty input: reset needle to show default ranked list (2.1). + // Consumed so prompt+buttons refresh, not stale filter. + if name.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); } - self.step = Step::Point { - name: name.to_string(), - }; + // Exact block name (case-insensitive) → accept and go to point step. + // This path is used both for typed exact names and for CmdOption + // button clicks (Message::CommandOptionPick feeds the keyword through + // on_text_input). Returning NeedPoint keeps the prompt updated. + if let Some(canonical) = self.picker.contains_name(name) { + self.step = Step::Point { name: canonical }; + return Some(CmdResult::NeedPoint); + } + // Not an exact match → treat as incremental search needle. + // Update filter and re-render prompt + option buttons. Must return + // Some(NeedPoint) (consumed) not None, otherwise the driver would + // offer the same text to the command a second time. + self.picker.set_needle(name.to_string()); Some(CmdResult::NeedPoint) } Step::FillAttr { .. } => Some(self.accept_attr_value(text)), diff --git a/src/modules/insert/minsert.rs b/src/modules/insert/minsert.rs index b91de8d94..55927a134 100644 --- a/src/modules/insert/minsert.rs +++ b/src/modules/insert/minsert.rs @@ -60,7 +60,7 @@ impl ParamIdx { } pub struct MinsertCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Collected array parameters (defaults applied as the user Enters through). rows: u16, @@ -71,9 +71,15 @@ pub struct MinsertCommand { } impl MinsertCommand { - pub fn new(available: Vec) -> Self { + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, rows: 1, columns: 1, @@ -122,12 +128,25 @@ impl CadCommand for MinsertCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("MINSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!("MINSERT No matching blocks for \"%{needle}\"", needle = needle).into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("MINSERT Enter block name:").into_owned() + } else { + t!("MINSERT Enter block name: [%{shown} of %{total} — type to search]", shown = shown, total = total).into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("MINSERT Enter block name:%{hint}", hint = hint).into_owned() + t!("MINSERT Enter block name: \"%{needle}\" [%{shown} matches]", needle = needle, shown = filtered.len()).into_owned() + } } Step::Point { name } => { t!( @@ -193,6 +212,25 @@ impl CadCommand for MinsertCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self.picker.filtered().iter().map(|n| crate::command::CmdOption::new(n, n)).collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn wants_text_input(&self) -> bool { matches!(self.step, Step::Name | Step::Params { .. }) } @@ -201,13 +239,15 @@ impl CadCommand for MinsertCommand { match &self.step { Step::Name => { let typed = text.trim(); - let matched = self - .available - .iter() - .find(|c| c.eq_ignore_ascii_case(typed))?; - self.step = Step::Point { - name: matched.clone(), - }; + if typed.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); + } + if let Some(matched) = self.picker.contains_name(typed) { + self.step = Step::Point { name: matched }; + return Some(CmdResult::NeedPoint); + } + self.picker.set_needle(typed.to_string()); Some(CmdResult::NeedPoint) } Step::Point { .. } => None, diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 81d6c68b8..d3b01ff9f 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -11,6 +11,7 @@ mod design_center; mod edit_block; pub(crate) mod insert_block; mod landxml; +pub(crate) mod picker; pub mod minsert; mod mview_block; mod open_obj; diff --git a/src/modules/insert/picker.rs b/src/modules/insert/picker.rs new file mode 100644 index 000000000..bc141be4a --- /dev/null +++ b/src/modules/insert/picker.rs @@ -0,0 +1,232 @@ +//! Reusable block-name picker for INSERT/MINSERT interactive search. +//! +//! Encapsulates the full list, lower/upper caches, usage ranking, +//! current needle and filtered view. Centralizes the O(n) scan + +//! partial-sort ranking so both commands share one optimized path +//! and future pickers can reuse it. + +use rustc_hash::FxHashMap; + +/// Maximum suggestion buttons, aligned with `CLIPROMPTLINES` range (0-50). +/// The actual limit is `CLIPROMPTLINES` — number of options is in direct +/// relation to allowed prompt lines, per spec. This constant is the hard cap +/// for performance (widget tree) and matches the sysvar max. +pub const MAX_SUGGESTIONS: usize = 50; + +#[derive(Debug, Clone)] +pub struct BlockPicker { + available: Vec, + lower_cache: Vec, + upper_cache: Vec, + filtered: Vec, + needle: String, + limit: usize, + usage_rank: FxHashMap, +} + +impl BlockPicker { + /// Create a picker. `available` is the full block name list (unsorted). + /// `usage_rank` is `UPPER -> (freq, mru_idx)`. `limit` is capped to + /// `MAX_SUGGESTIONS` by caller (or passed as `MAX_SUGGESTIONS` directly). + pub fn new(mut available: Vec, usage_rank: FxHashMap, limit: usize) -> Self { + let limit = limit.clamp(0, MAX_SUGGESTIONS); + // Rank `available` for the empty-needle view. Use partial sort when + // the list is large to avoid O(n log n) when we only need top `limit`. + if !usage_rank.is_empty() && available.len() > limit && limit > 0 { + // Build scored indices and select top `limit` before full sort. + let mut indices: Vec = (0..available.len()).collect(); + indices.select_nth_unstable_by(limit - 1, |&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + // higher freq first, smaller mru first, then alpha + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + indices.truncate(limit); + indices.sort_by(|&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + let mut ranked: Vec = indices.into_iter().map(|i| available[i].clone()).collect(); + // Append the rest unsorted — they are only needed when filtering. + // To keep `available` complete for filtering, retain all names but + // move the ranked top to front. Simpler: sort fully if limit==available.len(). + // For large n, we still want the rest available for substring search. + // So rebuild: ranked top + remaining in arbitrary order. + let mut remaining: Vec = available.into_iter().filter(|name| !ranked.contains(name)).collect(); + ranked.append(&mut remaining); + available = ranked; + } else if available.len() > 1 { + // Small n or no usage_rank: full sort is cheap and gives deterministic empty-needle order. + if !usage_rank.is_empty() { + available.sort_by(|a, b| { + let up_a = a.to_ascii_uppercase(); + let up_b = b.to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| a.cmp(b)) + }); + } else { + available.sort(); + } + } + + let lower_cache: Vec = available.iter().map(|s| s.to_ascii_lowercase()).collect(); + let upper_cache: Vec = available.iter().map(|s| s.to_ascii_uppercase()).collect(); + let filtered = Self::filter_ranked_inner(&available, &lower_cache, &upper_cache, "", &usage_rank, limit); + Self { + available, + lower_cache, + upper_cache, + filtered, + needle: String::new(), + limit, + usage_rank, + } + } + + /// Current needle (as typed, trimmed). + pub fn needle(&self) -> &str { + &self.needle + } + + /// Filtered view (≤ limit). + pub fn filtered(&self) -> &[String] { + &self.filtered + } + + /// Full list length. + pub fn total(&self) -> usize { + self.available.len() + } + + /// Whether the full list is empty. + pub fn is_empty(&self) -> bool { + self.available.is_empty() + } + + /// Does `available` contain `name` case-insensitively? + pub fn contains_name(&self, name: &str) -> Option { + self.available.iter().find(|c| c.eq_ignore_ascii_case(name)).cloned() + } + + /// Update needle and recompute `filtered`. Pass trimmed `needle`. + /// Handles empty string as reset to default ranked view. + pub fn set_needle(&mut self, needle: String) { + self.needle = needle; + let needle_ref = self.needle.as_str(); + self.filtered = Self::filter_ranked_inner( + &self.available, + &self.lower_cache, + &self.upper_cache, + needle_ref, + &self.usage_rank, + self.limit, + ); + } + + /// Test helper: simulate typing without exact-match handling. + #[cfg(test)] + pub fn set_needle_for_test(&mut self, needle: &str) { + self.set_needle(needle.to_string()); + } + + fn filter_ranked_inner( + available: &[String], + lower_cache: &[String], + upper_cache: &[String], + needle: &str, + usage_rank: &FxHashMap, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + if needle.is_empty() { + return available.iter().take(limit).cloned().collect(); + } + let n = needle.to_ascii_lowercase(); + let mut scored: Vec<(usize, u8, i32, usize)> = Vec::new(); + scored.reserve(available.len().min(limit * 4 + 16)); + for (i, lc) in lower_cache.iter().enumerate() { + if let Some(pos) = lc.find(n.as_str()) { + // Use upper_cache to avoid allocating per block per keystroke (1.1). + let up = &upper_cache[i]; + let (freq, mru) = usage_rank.get(up).copied().unwrap_or((0, usize::MAX)); + let prefix = if pos == 0 { 0 } else { 1 }; + scored.push((i, prefix, -(freq as i32), mru)); + } + } + if scored.is_empty() { + return Vec::new(); + } + // Partial sort: only need top `limit` (1.2). + let comparator = |a: &(usize, u8, i32, usize), b: &(usize, u8, i32, usize)| { + a.1.cmp(&b.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| a.3.cmp(&b.3)) + .then_with(|| available[a.0].cmp(&available[b.0])) + }; + if scored.len() > limit { + scored.select_nth_unstable_by(limit - 1, comparator); + scored.truncate(limit); + } + scored.sort_by(comparator); + scored.into_iter().map(|(i, _, _, _)| available[i].clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustc_hash::FxHashMap; + + fn picker(names: &[&str], usage: Vec<(&str, u32, usize)>, limit: usize) -> BlockPicker { + let mut map = FxHashMap::default(); + for (name, freq, idx) in usage { map.insert(name.to_ascii_uppercase(), (freq, idx)); } + BlockPicker::new(names.iter().map(|s| s.to_string()).collect(), map, limit) + } + + #[test] + fn empty_needle_shows_ranked_top() { + let mut p = picker(&["Gamma","Alpha","Beta"], vec![("Beta",5,0),("Alpha",2,1)], 2); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + p.set_needle("".into()); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + } + + #[test] + fn substring_filter_prefix_first() { + let mut p = picker(&["Alpha","Alphabet","Beta","Alpine"], vec![], 8); + p.set_needle("Al".into()); + let f = p.filtered(); + assert!(f.iter().all(|n| n.to_lowercase().contains("al"))); + // prefix matches before substring — but all four contain Al at pos 0 except maybe edge, so alphabetical + assert_eq!(f.len(), 3); + } + + #[test] + fn empty_input_resets_to_default() { + let mut p = picker(&["A","B","C"], vec![], 2); + p.set_needle("B".into()); + assert_eq!(p.filtered(), &["B"]); + p.set_needle("".into()); + assert_eq!(p.filtered().len(), 2); + } + + #[test] + fn upper_cache_avoids_allocation_and_partial_sort_limits() { + let names: Vec = (0..1000).map(|i| format!("Block_{:04}", i)).collect(); + let mut p = BlockPicker::new(names, FxHashMap::default(), 8); + let start = std::time::Instant::now(); + for needle in ["B","Bl","Block_0","Block_00","a"] { + p.set_needle(needle.into()); + assert!(p.filtered().len() <= 8); + } + assert!(start.elapsed().as_millis() < 20, "too slow"); + } +} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index b229981c4..b9d7f5dbf 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -441,8 +441,8 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) - .map(|m| m.lods[0].indices.len() / 3) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0]) + .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index e33401cf8..c9232598a 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -59,7 +59,7 @@ fn strip_option_listing(s: &str) -> String { const MAX_HISTORY: usize = 64; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct CommandLine { pub input: String, /// Persistent literal-space mode (the `>` toggle button): while on, every @@ -103,6 +103,31 @@ pub struct CommandLine { /// render as buttons above the input row. Empty when no command is /// active or the current step offers no options. (#304) step_options: Vec, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, default 3). + cliprompt_lines: u8, +} + +impl Default for CommandLine { + fn default() -> Self { + Self { + input: String::new(), + literal_spaces: false, + history: Vec::new(), + cmd_recall: Vec::new(), + recent_commands: Vec::new(), + recall_cursor: None, + recall_draft: String::new(), + history_open: false, + history_height: 0.0, + autocomplete_cursor: None, + dynamic_commands: Vec::new(), + command_aliases: rustc_hash::FxHashMap::default(), + step_prompt: None, + step_options: Vec::new(), + cliprompt_lines: 3, + } + } } #[derive(Clone, Debug)] @@ -347,11 +372,32 @@ impl CommandLine { self.step_options = opts; } + pub fn set_cliprompt_lines(&mut self, n: u8) { + self.cliprompt_lines = n.min(50); + } + + /// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests. + #[cfg(test)] + pub fn visible_history_count(&self) -> usize { + if self.cliprompt_lines == 0 { + return 0; + } + let visible: Vec<&HistoryEntry> = self + .history + .iter() + .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) + .collect(); + visible.len().min(self.cliprompt_lines as usize) + } + /// `true` while at least one history entry is still within the /// visible window — the host app uses this to drive a low-frequency /// tick subscription so the overlay re-renders and fades the entry /// once it expires. pub fn has_visible_history(&self) -> bool { + if self.cliprompt_lines == 0 { + return false; + } self.history .iter() .any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) @@ -464,7 +510,12 @@ impl CommandLine { let pinned = visible.remove(index); visible.push(pinned); } - let start = visible.len().saturating_sub(4); + let clip = self.cliprompt_lines as usize; + // CLIPROMPTLINES == 0 means no temporary prompt lines above command window. + if clip == 0 { + visible.clear(); + } + let start = visible.len().saturating_sub(clip); let has_recent_history = start < visible.len(); let history_rows = visible[start..] .iter()