diff --git a/assets/ocad.pgp b/assets/ocad.pgp index cf8ba9bc6..bde60d84f 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -56,6 +56,7 @@ LEN, *LENGTHEN AL, *ALIGN AR, *ARRAY MA, *MATCHPROP +VV, *QLAYER ATE, *ATTEDIT ; ── Clipboard ───────────────────────────────────────────────────────────── diff --git a/src/app/alias.rs b/src/app/alias.rs index 931e1fa3d..73b3156cb 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -80,7 +80,7 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 /// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES). -const DEFAULT_ALIASES_VERSION: u32 = 5; +const DEFAULT_ALIASES_VERSION: u32 = 6; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -100,6 +100,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { 3 => &[("HB", "HATCHTOBACK")], 4 => &[("ER", "EXTERNALREFERENCES")], 5 => &[("-ATT", "-ATTDEF")], + 6 => &[("VV", "QLAYER")], _ => &[], } } diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 17355fdf3..5b28a3327 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -204,6 +204,7 @@ impl OpenCADStudio { 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.command_line.clear_contextual_suggestions(); self.restore_add_selected_defaults(); self.tabs[i].pending_pause_tokens = None; } diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 22ce5eb3a..ca28997bc 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -628,6 +628,119 @@ impl OpenCADStudio { .push_info(crate::t!("LAYUNISO: all layers restored.").as_ref()); } + "QLAYER" => { + use crate::modules::draw::layers::quick_layer::{ + QuickLayerCommand, + QuickLayerEntry, + }; + + let layers: Vec = self.tabs[i] + .layers + .layers + .iter() + .filter(|layer| !layer.name.starts_with('*')) + .map(|layer| { + QuickLayerEntry::new( + layer.name.clone(), + layer.color.clone(), + layer.visible, + layer.frozen, + layer.locked, + ) + }) + .collect(); + + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, _)| handle) + .collect(); + + let command = QuickLayerCommand::new_with_entries(layers, selected); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + cmd if cmd.starts_with("QLAYER_APPLY_HEX ") => { + use crate::modules::draw::layers::quick_layer::QuickLayerCommand; + + let encoded = cmd.trim_start_matches("QLAYER_APPLY_HEX").trim(); + + let Some(requested) = QuickLayerCommand::decode_layer_name(encoded) else { + self.command_line.push_error( + crate::t!("QLAYER: invalid encoded layer name.").as_ref(), + ); + return Some(Task::none()); + }; + + let resolved = self.tabs[i] + .scene + .document + .layers + .names() + .find(|name| name.eq_ignore_ascii_case(&requested)) + .map(|name| name.to_string()); + + let Some(layer) = resolved else { + self.command_line.push_error( + crate::tf!("QLAYER: no layer named \"{requested}\".").as_ref(), + ); + return Some(Task::none()); + }; + + let handles: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, _)| handle) + .collect(); + + if handles.is_empty() { + match self.set_current_layer_name(i, &layer) { + Ok(()) => { + self.command_line.push_info( + crate::tf!( + "QLAYER: current layer set to \"{layer}\"." + ) + .as_ref(), + ); + } + Err(error) => { + self.command_line.push_error(&error); + } + } + } else { + let count = handles.len(); + + self.apply_property_op( + i, + "QLAYER", + &handles, + |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_common_prop( + entity, + "layer", + &layer, + ); + } + }, + ); + + self.ribbon.active_layer = layer.clone(); + + self.command_line.push_info( + crate::tf!( + "QLAYER: moved {count} object(s) to layer \"{layer}\"." + ) + .as_ref(), + ); + } + } + "LAYMATCH" | "LAYMCH" => { use crate::modules::draw::layers::match_layer::LayMatchCommand; let dest: Vec<_> = self.tabs[i] diff --git a/src/app/mod.rs b/src/app/mod.rs index 69f7169e6..cb9aa099d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2600,8 +2600,11 @@ pub enum Message { /// Pop the trailing character off the command-line input — backspace /// counterpart to `CommandAppendChar`. CommandBackspace, - /// TAB pressed: move focus to the next dynamic-input field (wraps). + /// TAB pressed: move focus to the next dynamic-input field or + /// autocomplete suggestion. DynTabNext, + /// Shift+TAB: move to the previous contextual autocomplete suggestion. + DynTabPrev, /// Split the active Model viewport in two. `true` → horizontal divider /// (top / bottom); `false` → vertical divider (left / right). SplitModelViewport(bool), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 4f0d342df..a72ec4b0d 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -50,6 +50,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("DELETE".to_string(), "DELETESELECTED"), ("BACKSPACE".to_string(), "BACKSPACE"), ("TAB".to_string(), "DYNTAB"), + ("SHIFT+TAB".to_string(), "DYNTABPREV"), ("UP".to_string(), "HISTORYPREV"), ("DOWN".to_string(), "HISTORYNEXT"), ("LEFT".to_string(), "CARETLEFT"), @@ -154,6 +155,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[ "DELETESELECTED", "BACKSPACE", "DYNTAB", + "DYNTABPREV", "HISTORYPREV", "HISTORYNEXT", "CARETLEFT", @@ -310,6 +312,7 @@ impl OpenCADStudio { "DELETESELECTED" => Message::DeleteSelected, "BACKSPACE" => Message::CommandBackspace, "DYNTAB" => Message::DynTabNext, + "DYNTABPREV" => Message::DynTabPrev, "HISTORYPREV" => Message::CommandHistoryPrev, "HISTORYNEXT" => Message::CommandHistoryNext, "CARETLEFT" => Message::MTextCaretMove(-1), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 677b3762b..456c27092 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -303,9 +303,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // 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()) }; + let (should_update, opts, prompt, suggestions) = + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + let changed = cmd.on_live_input(&live); + let suggestions = cmd.text_suggestions(&live); + if changed { + (true, cmd.options(), cmd.prompt(), suggestions) + } else { + (false, Vec::new(), String::new(), suggestions) + } + } else { + (false, Vec::new(), String::new(), Vec::new()) + }; + + self.command_line + .set_contextual_suggestions(suggestions); + 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; } } @@ -727,6 +740,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.autocomplete_cursor = None; return self.dispatch_command(&command); } + } else if self.command_line.has_contextual_suggestions() { + // During an active command Enter accepts the highlighted + // contextual value instead of the user's partial search. + if let Some(value) = self.command_line.selected_suggestion() { + self.command_line.input = value; + } } let i = self.active_tab; // A whole multi-token command line (`UCS Z 90`, `LINE 0,0 diff --git a/src/app/update/context_menu.rs b/src/app/update/context_menu.rs index 1f2df688e..7713cf88d 100644 --- a/src/app/update/context_menu.rs +++ b/src/app/update/context_menu.rs @@ -258,6 +258,7 @@ impl OpenCADStudio { } }, Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)), + Message::DynTabPrev => Some(self.on_context_menu_navigate(ContextMenuNav::Up)), Message::CommandFinalize | Message::CommandSpace => { if self.command_line.input.trim().is_empty() { Some(self.on_context_menu_navigate(ContextMenuNav::Enter)) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c80cc84b6..a1dee8a1d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1359,6 +1359,9 @@ impl OpenCADStudio { self.shortcut_bindings .entry("F5".to_string()) .or_insert_with(|| "ISOPLANE".to_string()); + self.shortcut_bindings + .entry("SHIFT+TAB".to_string()) + .or_insert_with(|| "DYNTABPREV".to_string()); } /// Write the config only when it changed since the last write, so a toggle diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b4bac5d8b..202eff1b6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -24,6 +24,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { | Message::CommandLineArrowProbe { .. } | Message::CommandLineArrowResolved { .. } | Message::DynTabNext + | Message::DynTabPrev | Message::MTextCaretMove(_) | Message::DeleteSelected | Message::ToggleSnapEnabled @@ -2156,16 +2157,22 @@ impl OpenCADStudio { 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) = + let (should_update, opts, prompt, suggestions) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - if cmd.on_live_input(&live_input) { - (true, cmd.options(), cmd.prompt()) + let changed = cmd.on_live_input(&live_input); + let suggestions = cmd.text_suggestions(&live_input); + if changed { + (true, cmd.options(), cmd.prompt(), suggestions) } else { - (false, Vec::new(), String::new()) + (false, Vec::new(), String::new(), suggestions) } } else { - (false, Vec::new(), String::new()) + (false, Vec::new(), String::new(), Vec::new()) }; + + self.command_line + .set_contextual_suggestions(suggestions); + if should_update { self.command_line.set_step_options(opts); // Update pinned prompt text live without pushing new history entry @@ -2194,6 +2201,12 @@ impl OpenCADStudio { } Message::DynTabNext => { + if self.command_line.has_contextual_suggestions() + && self.command_line.autocomplete_next() + { + return Task::none(); + } + let i = self.active_tab; let n = self.tabs[i].dyn_fields.len(); if n > 0 { @@ -2205,6 +2218,13 @@ impl OpenCADStudio { self.focus_cmd_input() } + Message::DynTabPrev => { + if self.command_line.has_contextual_suggestions() { + self.command_line.autocomplete_prev(); + } + self.focus_cmd_input() + } + Message::SplitModelViewport(horizontal) => { let i = self.active_tab; self.tabs[i].scene.split_active_pane(horizontal); @@ -2241,7 +2261,8 @@ impl OpenCADStudio { } let i = self.active_tab; if !self.command_line.history_navigation_active() - && self.tabs[i].active_cmd.is_none() + && (self.tabs[i].active_cmd.is_none() + || self.command_line.has_contextual_suggestions()) && self.command_line.autocomplete_prev() { return Task::none(); @@ -2267,7 +2288,8 @@ impl OpenCADStudio { } let i = self.active_tab; if !self.command_line.history_navigation_active() - && self.tabs[i].active_cmd.is_none() + && (self.tabs[i].active_cmd.is_none() + || self.command_line.has_contextual_suggestions()) && self.command_line.autocomplete_next() { return Task::none(); @@ -2468,11 +2490,20 @@ impl OpenCADStudio { } } - Message::CommandSuggestionPick(cmd) => { + Message::CommandSuggestionPick(value) => { + self.command_line.close_history(); + + if self.tabs[self.active_tab].active_cmd.is_some() + && self.command_line.has_contextual_suggestions() + { + self.command_line.input = value; + self.command_line.autocomplete_cursor = None; + return self.on_command_submit(); + } + self.command_line.input.clear(); self.command_line.autocomplete_cursor = None; - self.command_line.close_history(); - self.dispatch_command(&cmd) + self.dispatch_command(&value) } Message::CommandOptionPick(kw) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 8f177d0dd..832a5a8f0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1924,7 +1924,9 @@ bg={bg_ms:.1}ms n={view_count}" // collecting its own input. A pending Lengthen / Radius / Arc Length // value is numeric input, not the prefix of a new command (for example, // `3` must not open the 3DALIGN / 3DARRAY suggestions). - let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none(); + let allow_autocomplete = self.grip_pending.is_none() + && (tab.active_cmd.is_none() + || self.command_line.has_contextual_suggestions()); // Dynamic input captures keystrokes when its fields are showing, // so the command-line field must release focus / its on_input. // The MText preview also captures keystrokes (typing edits it), so the diff --git a/src/command.rs b/src/command.rs index feb9be7f4..eaf478412 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2430,6 +2430,32 @@ pub struct PointPickContext { pub aperture_px: f32, } +/// One context-sensitive command-line autocomplete entry. +/// +/// Normal command-name autocomplete only needs `value`. Interactive commands +/// may optionally attach CAD display metadata, allowing the shared suggestion +/// popup to render richer rows without knowing which command supplied them. +#[derive(Clone, Debug)] +pub struct CommandSuggestion { + pub value: String, + pub color: Option, + pub visible: Option, + pub frozen: Option, + pub locked: Option, +} + +impl CommandSuggestion { + pub fn plain(value: impl Into) -> Self { + Self { + value: value.into(), + color: None, + visible: None, + frozen: None, + locked: None, + } + } +} + pub trait CadCommand: Send { /// Preserve source appearance for commands that extract existing entities. fn preserve_commit_style(&self) -> bool { @@ -2471,6 +2497,15 @@ pub trait CadCommand: Send { false } + /// Context-sensitive autocomplete entries for the command line while this + /// command is active. Unlike normal command-name completion these entries + /// may be drawing data such as layer, block or style names. + /// + /// Commands that do not provide contextual completion leave this empty. + fn text_suggestions(&self, _input: &str) -> Vec { + Vec::new() + } + /// 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/draw/layers/mod.rs b/src/modules/draw/layers/mod.rs index 7a486f7fb..b04009067 100644 --- a/src/modules/draw/layers/mod.rs +++ b/src/modules/draw/layers/mod.rs @@ -11,3 +11,4 @@ pub(super) mod make_current; pub mod laytrans; pub mod match_layer; pub(super) mod panel; +pub mod quick_layer; diff --git a/src/modules/draw/layers/quick_layer.rs b/src/modules/draw/layers/quick_layer.rs new file mode 100644 index 000000000..fc5c55bf0 --- /dev/null +++ b/src/modules/draw/layers/quick_layer.rs @@ -0,0 +1,308 @@ +use crate::command::{CadCommand, CmdResult, CommandSuggestion, InputKind}; +use codec::Handle; +use glam::DVec3; + +/// Fast searchable layer picker. +/// +/// If objects were selected when QLAYER started, the chosen layer is applied +/// to those objects. With no selection, the chosen layer becomes current. +#[derive(Clone, Debug)] +pub struct QuickLayerEntry { + name: String, + color: Option, + visible: bool, + frozen: bool, + locked: bool, +} + +impl QuickLayerEntry { + pub fn new( + name: String, + color: codec::types::Color, + visible: bool, + frozen: bool, + locked: bool, + ) -> Self { + Self { + name, + color: Some(color), + visible, + frozen, + locked, + } + } + + fn plain(name: String) -> Self { + Self { + name, + color: None, + visible: true, + frozen: false, + locked: false, + } + } +} + +pub struct QuickLayerCommand { + layers: Vec, + selected: Vec, +} + +impl QuickLayerCommand { + pub fn new(layers: Vec, selected: Vec) -> Self { + let entries = layers + .into_iter() + .map(QuickLayerEntry::plain) + .collect(); + + Self::new_with_entries(entries, selected) + } + + pub fn new_with_entries( + mut layers: Vec, + selected: Vec, + ) -> Self { + layers.sort_by_key(|layer| layer.name.to_lowercase()); + layers.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name)); + + Self { layers, selected } + } + + fn fuzzy_score(name: &str, input: &str) -> Option<(u8, usize, usize, String)> { + let name_lower = name.to_lowercase(); + let query = input.trim().to_lowercase(); + + if query.is_empty() { + return None; + } + + // Exact match. + if name_lower == query { + return Some((0, 0, name_lower.len(), name_lower)); + } + + // Begins with typed text. + if name_lower.starts_with(&query) { + return Some((1, 0, name_lower.len(), name_lower)); + } + + // A word inside the layer begins with typed text. + for (index, word) in name_lower + .split(|c: char| matches!(c, ' ' | '-' | '_' | '.')) + .enumerate() + { + if word.starts_with(&query) { + return Some((2, index, name_lower.len(), name_lower)); + } + } + + // Normal substring. + if let Some(position) = name_lower.find(&query) { + return Some((3, position, name_lower.len(), name_lower)); + } + + // Approximate fallback: ordered subsequence. + // Example: "cte" can still find "Corte". + let chars: Vec = name_lower.chars().collect(); + let mut cursor = 0usize; + let mut gaps = 0usize; + + for wanted in query.chars() { + let Some(relative) = chars[cursor..] + .iter() + .position(|candidate| *candidate == wanted) + else { + return None; + }; + + gaps += relative; + cursor += relative + 1; + } + + Some((4, gaps, name_lower.len(), name_lower)) + } + + fn matches(&self, input: &str) -> Vec { + let mut matches: Vec<_> = self + .layers + .iter() + .filter_map(|layer| { + Self::fuzzy_score(&layer.name, input) + .map(|score| (score, layer.name.clone())) + }) + .collect(); + + matches.sort_by(|a, b| a.0.cmp(&b.0)); + + matches + .into_iter() + .map(|(_, name)| name) + .take(8) + .collect() + } + + + fn encode_layer_name(name: &str) -> String { + let mut out = String::with_capacity(name.len() * 2); + for byte in name.as_bytes() { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02X}"); + } + out + } + + pub(crate) fn decode_layer_name(encoded: &str) -> Option { + if encoded.len() % 2 != 0 { + return None; + } + + let mut bytes = Vec::with_capacity(encoded.len() / 2); + let chars: Vec = encoded.chars().collect(); + + for i in (0..chars.len()).step_by(2) { + let hi = chars[i].to_digit(16)?; + let lo = chars[i + 1].to_digit(16)?; + bytes.push(((hi << 4) | lo) as u8); + } + + String::from_utf8(bytes).ok() + } + +fn canonical_layer(&self, input: &str) -> Option { + self.layers + .iter() + .find(|layer| layer.name.eq_ignore_ascii_case(input.trim())) + .map(|layer| layer.name.clone()) + } +} + +impl CadCommand for QuickLayerCommand { + fn name(&self) -> &'static str { + "QLAYER" + } + + fn prompt(&self) -> String { + if self.selected.is_empty() { + crate::t!("QLAYER Search current layer:").into_owned() + } else { + crate::tf!( + "QLAYER Search destination layer for {} object(s):", + self.selected.len() + ) + .into_owned() + } + } + + fn input_kind(&self) -> InputKind { + // Layer names may legitimately contain spaces. Treat the search as one + // complete text value so "Vista 1" is not split into "Vista" + "1". + InputKind::FreeText + } + + fn text_suggestions(&self, input: &str) -> Vec { + self.matches(input) + .into_iter() + .filter_map(|name| { + self.layers + .iter() + .find(|layer| layer.name == name) + .map(|layer| CommandSuggestion { + value: layer.name.clone(), + color: layer.color.clone(), + visible: Some(layer.visible), + frozen: Some(layer.frozen), + locked: Some(layer.locked), + }) + }) + .collect() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let Some(layer) = self.canonical_layer(text) else { + return Some(CmdResult::ReportError(format!( + "QLAYER: no layer named \"{}\".", + text.trim() + ))); + }; + + Some(CmdResult::Relaunch( + format!( + "QLAYER_APPLY_HEX {}", + Self::encode_layer_name(&layer) + ), + std::mem::take(&mut self.selected), + )) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::NeedPoint + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["QLAYER"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + fn command() -> QuickLayerCommand { + QuickLayerCommand::new( + vec![ + "Corte".into(), + "Corte 2".into(), + "Curvas de nivel".into(), + "E-Proyecciones".into(), + "Propiedad".into(), + "Proyección".into(), + ], + Vec::new(), + ) + } + + #[test] + fn exact_match_ranks_first() { + let matches = command().matches("corte"); + + assert_eq!(matches.first().map(String::as_str), Some("Corte")); + } + + #[test] + fn prefix_search_finds_layer() { + let matches = command().matches("pro"); + + assert!(matches.iter().any(|name| name == "Proyección")); + assert!(matches.iter().any(|name| name == "Propiedad")); + } + + #[test] + fn substring_search_finds_layer() { + let matches = command().matches("nivel"); + + assert_eq!( + matches.first().map(String::as_str), + Some("Curvas de nivel") + ); + } + + #[test] + fn approximate_search_finds_layer() { + let matches = command().matches("cte"); + + assert!(matches.iter().any(|name| name == "Corte")); + } + + #[test] + fn layer_name_roundtrip_preserves_spaces() { + let original = "Vista 1"; + let encoded = QuickLayerCommand::encode_layer_name(original); + let decoded = QuickLayerCommand::decode_layer_name(&encoded); + + assert_eq!(decoded.as_deref(), Some(original)); + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 5704e42a4..0b30fec00 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -168,6 +168,10 @@ pub struct CommandLine { /// means the first match is pre-selected (highlighted) before keyboard /// navigation begins. Reset when input changes. pub autocomplete_cursor: Option, + /// Suggestions supplied by the active CAD command. When non-empty these + /// replace normal command-name autocomplete, allowing commands to offer + /// drawing-specific values such as layers, blocks or styles. + pub contextual_suggestions: Vec, /// Command names contributed by loaded plugins, refreshed whenever the /// enabled-plugin set changes. Merged into autocomplete alongside the /// compile-time command registry, so runtime plugin commands are typeable @@ -212,6 +216,7 @@ impl Default for CommandLine { history_open: false, history_height: 0.0, autocomplete_cursor: None, + contextual_suggestions: Vec::new(), dynamic_commands: Vec::new(), command_aliases: rustc_hash::FxHashMap::default(), step_prompt: None, @@ -607,6 +612,34 @@ impl CommandLine { self.step_prompt = None; } + /// Replace the active command's context-sensitive suggestions. + /// A new result set always resets keyboard navigation to the first item. + pub fn set_contextual_suggestions( + &mut self, + suggestions: Vec, + ) { + let changed = self + .contextual_suggestions + .iter() + .map(|entry| entry.value.as_str()) + .ne(suggestions.iter().map(|entry| entry.value.as_str())); + + self.contextual_suggestions = suggestions; + + if changed { + self.autocomplete_cursor = None; + } + } + + pub fn clear_contextual_suggestions(&mut self) { + self.contextual_suggestions.clear(); + self.autocomplete_cursor = None; + } + + pub fn has_contextual_suggestions(&self) -> bool { + !self.contextual_suggestions.is_empty() + } + /// Move the autocomplete highlight up one entry. Wraps to the last match. pub fn autocomplete_prev(&mut self) -> bool { let len = self.autocomplete_matches().len(); @@ -648,6 +681,14 @@ impl CommandLine { /// Autocomplete suggestions for the current input — see /// [`ranked_matches`]. Includes loaded plugins' commands (#272). pub fn autocomplete_matches(&self) -> Vec { + if !self.contextual_suggestions.is_empty() { + return self + .contextual_suggestions + .iter() + .map(|entry| entry.value.clone()) + .collect(); + } + ranked_matches( self.input.trim(), &self.dynamic_commands, @@ -826,15 +867,64 @@ impl CommandLine { let mut col = column![].spacing(0).width(Length::Fill); for (idx, cmd) in matches.iter().enumerate() { let is_selected = idx == cursor; - // Every row keeps the icon's width so names line up. - let icon: Element<'_, Message> = - match crate::modules::registry::command_icon(cmd) { - Some(bytes) => crate::ui::icons::semantic(bytes, 14.0), - None => Space::new().width(14.0).into(), - }; - let label = row![icon, text(cmd.clone()).size(11)] - .spacing(6) - .align_y(iced::Center); + let details = self.contextual_suggestions.get(idx); + + let mut label = row![].spacing(6).align_y(iced::Center); + + if let Some(details) = details { + if let Some(visible) = details.visible { + label = label.push(crate::ui::icons::semantic( + crate::ui::icons::layer_visible(visible), + 14.0, + )); + } + + if let Some(frozen) = details.frozen { + label = label.push(crate::ui::icons::semantic( + crate::ui::icons::layer_freeze(frozen), + 14.0, + )); + } + + if let Some(locked) = details.locked { + label = label.push(crate::ui::icons::semantic( + crate::ui::icons::layer_lock(locked), + 14.0, + )); + } + + if let Some(color) = details.color.clone() { + let swatch_color = + crate::ui::window::layers::iced_color_from_acad(&color); + + let swatch = container(text("")) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(swatch_color)), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 1.0.into(), + }, + ..Default::default() + }) + .width(12) + .height(12); + + label = label.push(swatch); + } + } else { + // Normal command autocomplete keeps its existing command icon. + let icon: Element<'_, Message> = + match crate::modules::registry::command_icon(cmd) { + Some(bytes) => crate::ui::icons::semantic(bytes, 14.0), + None => Space::new().width(14.0).into(), + }; + + label = label.push(icon); + } + + label = label.push(text(cmd.clone()).size(11)); + let row = button(label) .on_press(Message::CommandSuggestionPick(cmd.clone())) .width(Length::Fill)