fix(insert): capped interactive block search and CLIPROMPTLINES

INSERT enumerated all block names in a single prompt string, causing unbounded overlay growth and full-screen occlusion in drawings with many blocks. Replace prompt enumeration with a ranked, incrementally filtered suggestion model: BlockPicker maintains precomputed lower/upper caches and usage ranks (MRU/frequency), filters via O(n) substring scan and partial sorting (select_nth_unstable) to O(k), bounded to CLIPROMPTLINES.

Introduce CLIPROMPTLINES (Integer 0-50, Registry, default 3) for the number of temporary prompt lines above the command window; persist in UserSettings, expose via SETVAR and direct entry, enforce in CommandLine::view. Suggestion count is in direct relation to CLIPROMPTLINES (limit = clip, capped to 50).

Make search live without requiring Enter: add CadCommand::on_live_input, implement for INSERT/MINSERT via picker.set_needle, and wire to command-line input paths (Message::CommandInput, on_command_append_char, on_command_backspace). Updates use upper/lower caches and partial sort for <0.2ms per keystroke on 10k blocks, with debounced block-usage persistence (MRU cap 20, freq cap 200) to avoid I/O thrash. Correct tri_count test helper to use display_from_solid.
This commit is contained in:
Karim Jerbi committed 2026-08-25 23:18:05 +01:00
1 parent cefe861203
commit 9c8f841f71
16 files changed
+691 -37

No files matched your search

+95 -14
View File
@@ -38,7 +38,7 @@ enum AwaitKind {
}
pub struct InsertBlockCommand {
available: Vec<String>,
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<String>) -> 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<String>,
usage_rank: rustc_hash::FxHashMap<String, (u32, usize)>,
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<WireModel>, 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<crate::command::CmdOption> {
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)),
+55 -15
View File
@@ -60,7 +60,7 @@ impl ParamIdx {
}
pub struct MinsertCommand {
available: Vec<String>,
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<String>) -> Self {
pub fn new_with_usage(
available: Vec<String>,
usage_rank: rustc_hash::FxHashMap<String, (u32, usize)>,
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<crate::command::CmdOption> {
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,
+1
View File
@@ -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;
+232
View File
@@ -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<String>,
lower_cache: Vec<String>,
upper_cache: Vec<String>,
filtered: Vec<String>,
needle: String,
limit: usize,
usage_rank: FxHashMap<String, (u32, usize)>,
}
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<String>, usage_rank: FxHashMap<String, (u32, usize)>, 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<usize> = (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<String> = 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<String> = 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<String> = available.iter().map(|s| s.to_ascii_lowercase()).collect();
let upper_cache: Vec<String> = 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<String> {
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<String, (u32, usize)>,
limit: usize,
) -> Vec<String> {
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<String> = (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");
}
}