fix(input): restore arrow key navigation
Route editor and command-line arrows by active context and recall successfully dispatched commands.\n\nRefs #485
This commit is contained in:
parent
657d023fb7
commit
474bfcc7db
9 changed files with 330 additions and 64 deletions
|
|
@ -3089,13 +3089,18 @@ impl OpenCADStudio {
|
|||
self.restore_pre_cmd_tangent();
|
||||
}
|
||||
}
|
||||
// Keep the command-line input focused at all times — every typed
|
||||
// character is meant to route there (the command processor reads
|
||||
// its keystroke stream from this widget). When no command is
|
||||
// running the ribbon tool button still has to visually deactivate.
|
||||
// When no command is running the ribbon tool button still has to
|
||||
// visually deactivate. Keyboard focus is assigned below to whichever
|
||||
// editor currently owns typed input.
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
// The rich text canvas owns keyboard editing itself. Leaving the
|
||||
// hidden command input focused would make it consume Left/Right before
|
||||
// the editor can handle them.
|
||||
if self.mtext_editor.is_some() {
|
||||
return self.unfocus_widgets();
|
||||
}
|
||||
// The in-place TEXT editor needs keyboard focus on its own field.
|
||||
if self.text_inline.is_some() {
|
||||
return iced::widget::operation::focus(iced::widget::Id::new(
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,14 @@ pub enum DsField {
|
|||
Dimtzin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ArrowKey {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Tick(Instant),
|
||||
|
|
@ -1826,6 +1834,18 @@ pub enum Message {
|
|||
CommandHistoryPrev,
|
||||
/// Recall next command in history (↓ arrow key).
|
||||
CommandHistoryNext,
|
||||
/// An unconsumed arrow key; the active editor gets first choice, otherwise
|
||||
/// the configurable shortcut table handles it.
|
||||
ArrowKeyPressed {
|
||||
direction: ArrowKey,
|
||||
shortcut: String,
|
||||
extend_selection: bool,
|
||||
},
|
||||
/// A widget captured Up/Down; resolve it only if the command input owns
|
||||
/// keyboard focus.
|
||||
CommandLineArrowProbe { direction: ArrowKey },
|
||||
/// Result of the command-input focus query for a captured Up/Down key.
|
||||
CommandLineArrowResolved { direction: ArrowKey, focused: bool },
|
||||
/// Toggle the dropdown listing the full command-line history.
|
||||
CommandHistoryToggle,
|
||||
/// Grab/move/release the expanded history panel's top resize edge.
|
||||
|
|
|
|||
|
|
@ -538,6 +538,63 @@ fn cells_delete_range(cells: &mut Vec<Cell>, mut a: usize, mut b: usize) -> usiz
|
|||
use crate::scene::convert::tessellate;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
fn vertical_caret_target(
|
||||
boxes: &[crate::entities::text_support::GlyphBox],
|
||||
caret: usize,
|
||||
visible_count: usize,
|
||||
direction: i8,
|
||||
) -> usize {
|
||||
if boxes.is_empty() || direction == 0 {
|
||||
return caret.min(visible_count);
|
||||
}
|
||||
|
||||
let anchor = boxes
|
||||
.iter()
|
||||
.find(|item| item.vis == caret)
|
||||
.map(|item| (item.xmin, item.ymin, item.ymax))
|
||||
.or_else(|| {
|
||||
caret.checked_sub(1).and_then(|previous| {
|
||||
boxes
|
||||
.iter()
|
||||
.find(|item| item.vis == previous)
|
||||
.map(|item| (item.xmax, item.ymin, item.ymax))
|
||||
})
|
||||
})
|
||||
.or_else(|| boxes.first().map(|item| (item.xmin, item.ymin, item.ymax)));
|
||||
let Some((anchor_x, anchor_y, anchor_top)) = anchor else {
|
||||
return caret.min(visible_count);
|
||||
};
|
||||
|
||||
let sign = f32::from(direction.signum());
|
||||
let line_epsilon = ((anchor_top - anchor_y).abs() * 0.15).max(1e-5);
|
||||
let nearest_line = boxes
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let distance = (item.ymin - anchor_y) * sign;
|
||||
(distance > line_epsilon).then_some(distance)
|
||||
})
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
if !nearest_line.is_finite() {
|
||||
return caret.min(visible_count);
|
||||
}
|
||||
|
||||
let line_tolerance = line_epsilon.max(nearest_line * 0.15);
|
||||
let mut best = (f32::INFINITY, caret.min(visible_count));
|
||||
for item in boxes {
|
||||
let distance = (item.ymin - anchor_y) * sign;
|
||||
if (distance - nearest_line).abs() > line_tolerance {
|
||||
continue;
|
||||
}
|
||||
for (x, offset) in [(item.xmin, item.vis), (item.xmax, item.vis + 1)] {
|
||||
let score = (x - anchor_x).abs();
|
||||
if score < best.0 {
|
||||
best = (score, offset.min(visible_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
impl super::OpenCADStudio {
|
||||
/// Open the in-place editor for a new (`handle = None`) or existing MText.
|
||||
/// Open the rich MText editor for a new or existing MText / MultiLeader.
|
||||
|
|
@ -613,6 +670,7 @@ impl super::OpenCADStudio {
|
|||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.caret = end;
|
||||
ed.sel = Some((end, end));
|
||||
ed.sel_anchor = end;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -892,6 +950,7 @@ impl super::OpenCADStudio {
|
|||
text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string());
|
||||
ed.caret = caret;
|
||||
ed.sel = Some((caret, caret));
|
||||
ed.sel_anchor = caret;
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
|
|
@ -914,6 +973,7 @@ impl super::OpenCADStudio {
|
|||
text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string());
|
||||
ed.caret = caret;
|
||||
ed.sel = Some((caret, caret));
|
||||
ed.sel_anchor = caret;
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
|
|
@ -936,18 +996,58 @@ impl super::OpenCADStudio {
|
|||
text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string());
|
||||
ed.caret = caret;
|
||||
ed.sel = Some((caret, caret));
|
||||
ed.sel_anchor = caret;
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
/// Move the caret by `delta` visible characters (clears the selection).
|
||||
pub(super) fn mtext_caret_move(&mut self, delta: i32) {
|
||||
/// Move the caret horizontally by `delta` visible characters.
|
||||
pub(super) fn mtext_caret_move(&mut self, delta: i32, extend_selection: bool) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let n = doc_to_cells(&ed.doc).len() as i32;
|
||||
let c = (ed.caret as i32 + delta).clamp(0, n) as usize;
|
||||
let c = if extend_selection {
|
||||
(ed.caret as i32 + delta).clamp(0, n) as usize
|
||||
} else {
|
||||
match ed.sel {
|
||||
Some((start, end)) if start < end && delta < 0 => start.min(n as usize),
|
||||
Some((start, end)) if start < end && delta > 0 => end.min(n as usize),
|
||||
_ => (ed.caret as i32 + delta).clamp(0, n) as usize,
|
||||
}
|
||||
};
|
||||
ed.caret = c;
|
||||
ed.sel = Some((c, c));
|
||||
if extend_selection {
|
||||
ed.sel = Some((ed.sel_anchor.min(c), ed.sel_anchor.max(c)));
|
||||
} else {
|
||||
ed.sel = Some((c, c));
|
||||
ed.sel_anchor = c;
|
||||
}
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the caret to the visually adjacent text line while preserving its
|
||||
/// horizontal position as closely as the laid-out glyph boxes allow.
|
||||
pub(super) fn mtext_caret_move_vertical(
|
||||
&mut self,
|
||||
direction: i8,
|
||||
extend_selection: bool,
|
||||
) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let visible_count = doc_to_cells(&ed.doc).len();
|
||||
let caret = vertical_caret_target(
|
||||
&ed.glyph_boxes,
|
||||
ed.caret,
|
||||
visible_count,
|
||||
direction,
|
||||
);
|
||||
ed.caret = caret;
|
||||
if extend_selection {
|
||||
ed.sel = Some((ed.sel_anchor.min(caret), ed.sel_anchor.max(caret)));
|
||||
} else {
|
||||
ed.sel = Some((caret, caret));
|
||||
ed.sel_anchor = caret;
|
||||
}
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ impl super::OpenCADStudio {
|
|||
|
||||
if field.is_rich() {
|
||||
self.open_mtext_editor(pos, Some(target), &value, height);
|
||||
iced::Task::none()
|
||||
self.unfocus_widgets()
|
||||
} else {
|
||||
self.open_text_inline(pos, Some(target), &value, height, field);
|
||||
iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID))
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
} else {
|
||||
// Command-line entry is shown uppercase.
|
||||
self.command_line.input.push_str(&s.to_uppercase());
|
||||
self.command_line.cancel_history_navigation();
|
||||
}
|
||||
}
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
|
|
@ -221,6 +222,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
}
|
||||
self.command_line.input.pop();
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.cancel_history_navigation();
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
|
|
@ -303,15 +305,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
}
|
||||
return self.dispatch_command(&format!("SETVAR {name} {val}"));
|
||||
}
|
||||
// If the user navigated the autocomplete list with the
|
||||
// arrow keys, Enter dispatches the highlighted command
|
||||
// rather than the partial text actually in the buffer.
|
||||
let i_tab = self.active_tab;
|
||||
if self.tabs[i_tab].active_cmd.is_none() {
|
||||
if let Some(cmd) = self.command_line.selected_suggestion() {
|
||||
if let Some(command) = self.command_line.selected_suggestion() {
|
||||
self.command_line.input.clear();
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
return self.dispatch_command(&cmd);
|
||||
return self.dispatch_command(&command);
|
||||
}
|
||||
}
|
||||
let i = self.active_tab;
|
||||
|
|
|
|||
|
|
@ -421,7 +421,7 @@ impl OpenCADStudio {
|
|||
self.tabs[i].active_cmd = Some(cmd);
|
||||
self.apply_cmd_result(res)
|
||||
} else {
|
||||
Task::none()
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::{Message, OpenCADStudio};
|
||||
use super::{ArrowKey, Message, OpenCADStudio};
|
||||
use crate::scene::VIEWCUBE_DRAW_PX;
|
||||
use crate::ui::PropertiesPanel;
|
||||
use iced::time::Instant;
|
||||
|
|
@ -19,6 +19,9 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
|
|||
| Message::CommandBackspace
|
||||
| Message::CommandHistoryPrev
|
||||
| Message::CommandHistoryNext
|
||||
| Message::ArrowKeyPressed { .. }
|
||||
| Message::CommandLineArrowProbe { .. }
|
||||
| Message::CommandLineArrowResolved { .. }
|
||||
| Message::DynTabNext
|
||||
| Message::MTextCaretMove(_)
|
||||
| Message::DeleteSelected
|
||||
|
|
@ -1493,9 +1496,8 @@ impl OpenCADStudio {
|
|||
return self.update(Message::CommandSubmit);
|
||||
}
|
||||
self.command_line.input = s;
|
||||
// Typing invalidates the previous arrow-key cursor —
|
||||
// the matches list has likely changed.
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.cancel_history_navigation();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
|
@ -1558,14 +1560,17 @@ impl OpenCADStudio {
|
|||
}
|
||||
return Task::none();
|
||||
}
|
||||
// While autocomplete is showing suggestions, ↑ walks up
|
||||
// that list. Otherwise it falls back to recall history.
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none() && self.command_line.autocomplete_prev() {
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& self.command_line.autocomplete_prev()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.command_line.history_prev();
|
||||
Task::none()
|
||||
iced::widget::operation::move_cursor_to_end(iced::widget::Id::new(
|
||||
crate::ui::command_line::CMD_INPUT_ID,
|
||||
))
|
||||
}
|
||||
|
||||
Message::CommandHistoryNext => {
|
||||
|
|
@ -1582,11 +1587,63 @@ impl OpenCADStudio {
|
|||
return Task::none();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none() && self.command_line.autocomplete_next() {
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& self.command_line.autocomplete_next()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.command_line.history_next();
|
||||
Task::none()
|
||||
iced::widget::operation::move_cursor_to_end(iced::widget::Id::new(
|
||||
crate::ui::command_line::CMD_INPUT_ID,
|
||||
))
|
||||
}
|
||||
|
||||
Message::CommandLineArrowProbe { direction } => {
|
||||
iced::widget::operation::is_focused(iced::widget::Id::new(
|
||||
crate::ui::command_line::CMD_INPUT_ID,
|
||||
))
|
||||
.map(move |focused| Message::CommandLineArrowResolved {
|
||||
direction,
|
||||
focused,
|
||||
})
|
||||
}
|
||||
|
||||
Message::CommandLineArrowResolved { direction, focused } => {
|
||||
if !focused {
|
||||
return Task::none();
|
||||
}
|
||||
match direction {
|
||||
ArrowKey::Up => self.update(Message::CommandHistoryPrev),
|
||||
ArrowKey::Down => self.update(Message::CommandHistoryNext),
|
||||
ArrowKey::Left | ArrowKey::Right => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
Message::ArrowKeyPressed {
|
||||
direction,
|
||||
shortcut,
|
||||
extend_selection,
|
||||
} => {
|
||||
if self.mtext_editor.is_some() {
|
||||
match direction {
|
||||
ArrowKey::Left => self.mtext_caret_move(-1, extend_selection),
|
||||
ArrowKey::Right => self.mtext_caret_move(1, extend_selection),
|
||||
ArrowKey::Up => {
|
||||
self.mtext_caret_move_vertical(1, extend_selection)
|
||||
}
|
||||
ArrowKey::Down => {
|
||||
self.mtext_caret_move_vertical(-1, extend_selection)
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
} else {
|
||||
match direction {
|
||||
ArrowKey::Up => self.update(Message::CommandHistoryPrev),
|
||||
ArrowKey::Down => self.update(Message::CommandHistoryNext),
|
||||
ArrowKey::Left | ArrowKey::Right => self.run_shortcut(&shortcut),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Message::CommandLiteralToggle => {
|
||||
|
|
@ -1699,6 +1756,7 @@ impl OpenCADStudio {
|
|||
|
||||
Message::CommandSuggestionPick(cmd) => {
|
||||
self.command_line.input.clear();
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.close_history();
|
||||
self.dispatch_command(&cmd)
|
||||
}
|
||||
|
|
@ -3567,7 +3625,7 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
self.unfocus_widgets()
|
||||
}
|
||||
Message::MTextSelTo(off) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
|
|
@ -3585,7 +3643,7 @@ impl OpenCADStudio {
|
|||
{
|
||||
return self.update(Message::PropHatchPatternNavigate(d as i8));
|
||||
}
|
||||
self.mtext_caret_move(d);
|
||||
self.mtext_caret_move(d, false);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextCaretBlink => {
|
||||
|
|
@ -3629,6 +3687,7 @@ impl OpenCADStudio {
|
|||
let flat = text.replace(['\r', '\n'], " ").to_uppercase();
|
||||
self.command_line.input.push_str(&flat);
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.cancel_history_navigation();
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
Text::EmptyOrUnsupported => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use super::document::DocumentTab;
|
||||
use super::document::DynComponent;
|
||||
use super::history::history_dropdown_labels;
|
||||
use super::{Message, OpenCADStudio};
|
||||
use super::{ArrowKey, Message, OpenCADStudio};
|
||||
use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte};
|
||||
use crate::scene::view::viewport_pane::ViewportPane;
|
||||
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
|
||||
|
|
@ -1977,6 +1977,44 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
}
|
||||
let has_printable_text = text.as_deref().is_some_and(|value| {
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| !ch.is_control() && !ch.is_whitespace())
|
||||
});
|
||||
let arrow = match &key {
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
|
||||
Some(ArrowKey::Up)
|
||||
}
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
|
||||
Some(ArrowKey::Down)
|
||||
}
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
|
||||
Some(ArrowKey::Left)
|
||||
}
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
|
||||
Some(ArrowKey::Right)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if !has_printable_text && status == Status::Ignored {
|
||||
if let Some(direction) = arrow {
|
||||
return Some(Message::ArrowKeyPressed {
|
||||
direction,
|
||||
shortcut: shortcut_key_name(&key, modifiers)?,
|
||||
extend_selection: modifiers.shift(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !has_printable_text
|
||||
&& status == Status::Captured
|
||||
&& matches!(arrow, Some(ArrowKey::Up | ArrowKey::Down))
|
||||
{
|
||||
return Some(Message::CommandLineArrowProbe {
|
||||
direction: arrow?,
|
||||
});
|
||||
}
|
||||
// A focused web text field needs the browser clipboard;
|
||||
// drawing shortcuts only run for ignored C/V events.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -2004,6 +2042,12 @@ impl OpenCADStudio {
|
|||
pub(super) fn focus_cmd_input(&self) -> Task<Message> {
|
||||
iced::widget::operation::focus(iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID))
|
||||
}
|
||||
|
||||
pub(super) fn unfocus_widgets(&self) -> Task<Message> {
|
||||
iced::advanced::widget::operate(
|
||||
iced::advanced::widget::operation::focusable::unfocus(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Document tab bar ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ pub struct CommandLine {
|
|||
/// instead of submitting. Saved in the user config.
|
||||
pub literal_spaces: bool,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
/// Commands the user has typed (for ↑/↓ recall). Holds the raw typed
|
||||
/// strings so the line can be re-edited; distinct from `recent_commands`.
|
||||
/// Successfully dispatched commands used for ↑/↓ recall, newest last.
|
||||
/// Stored separately because recall also maintains its own cursor and draft.
|
||||
pub cmd_recall: Vec<String>,
|
||||
/// Commands actually dispatched, from any source (command line, ribbon,
|
||||
/// context menu, shortcuts), newest last. Drives the right-click "Repeat"
|
||||
|
|
@ -83,8 +83,7 @@ pub struct CommandLine {
|
|||
/// Persisted height of the full-history editor in logical pixels.
|
||||
pub history_height: f32,
|
||||
/// Index of the currently-highlighted autocomplete suggestion, or
|
||||
/// `None` when the user hasn't yet started navigating with the
|
||||
/// arrow keys. Reset on every keystroke.
|
||||
/// `None` before keyboard navigation begins. Reset when input changes.
|
||||
pub autocomplete_cursor: Option<usize>,
|
||||
/// Command names contributed by loaded plugins, refreshed whenever the
|
||||
/// enabled-plugin set changes. Merged into autocomplete alongside the
|
||||
|
|
@ -203,23 +202,15 @@ impl CommandLine {
|
|||
Some((verb, rest)) => format!("{} {}", verb.to_uppercase(), rest),
|
||||
None => raw.to_uppercase(),
|
||||
};
|
||||
// Record in recall list (avoid duplicates at the top).
|
||||
if self.cmd_recall.last().map(|s| s.as_str()) != Some(raw.as_str()) {
|
||||
self.cmd_recall.push(raw);
|
||||
if self.cmd_recall.len() > 50 {
|
||||
self.cmd_recall.remove(0);
|
||||
}
|
||||
}
|
||||
self.recall_cursor = None;
|
||||
self.recall_draft.clear();
|
||||
self.push_command(&self.input.clone());
|
||||
self.input.clear();
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
/// Record a dispatched command for the right-click "Repeat" menu, skipping
|
||||
/// a consecutive duplicate and capping the list. Called from the dispatch
|
||||
/// choke point so commands from every source are captured.
|
||||
/// Record a successfully dispatched command for both the right-click
|
||||
/// "Repeat" menu and ↑/↓ recall. Consecutive duplicates are skipped and
|
||||
/// both lists are capped. Called from the dispatch choke point so commands
|
||||
/// from every source are captured.
|
||||
pub fn record_recent(&mut self, cmd: &str) {
|
||||
let cmd = cmd.trim();
|
||||
if cmd.is_empty() {
|
||||
|
|
@ -231,6 +222,22 @@ impl CommandLine {
|
|||
self.recent_commands.remove(0);
|
||||
}
|
||||
}
|
||||
if self.cmd_recall.last().map(String::as_str) != Some(cmd) {
|
||||
self.cmd_recall.push(cmd.to_string());
|
||||
if self.cmd_recall.len() > 50 {
|
||||
self.cmd_recall.remove(0);
|
||||
}
|
||||
}
|
||||
self.cancel_history_navigation();
|
||||
}
|
||||
|
||||
pub fn history_navigation_active(&self) -> bool {
|
||||
self.recall_cursor.is_some()
|
||||
}
|
||||
|
||||
pub fn cancel_history_navigation(&mut self) {
|
||||
self.recall_cursor = None;
|
||||
self.recall_draft.clear();
|
||||
}
|
||||
|
||||
/// Navigate to the previous command in recall history (↑).
|
||||
|
|
@ -388,42 +395,41 @@ impl CommandLine {
|
|||
self.step_prompt = None;
|
||||
}
|
||||
|
||||
/// Move the autocomplete highlight up one entry. Wraps to the last
|
||||
/// match. Returns `true` when there was a list to navigate.
|
||||
/// 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();
|
||||
if len == 0 {
|
||||
return false;
|
||||
}
|
||||
// No explicit cursor means the top match (index 0) is highlighted,
|
||||
// so ↑ from there wraps to the last entry.
|
||||
let cur = self.autocomplete_cursor.unwrap_or(0);
|
||||
let next = if cur == 0 { len - 1 } else { cur - 1 };
|
||||
self.autocomplete_cursor = Some(next);
|
||||
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
|
||||
self.autocomplete_cursor = Some(if current == 0 {
|
||||
len - 1
|
||||
} else {
|
||||
current - 1
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Move the autocomplete highlight down one entry. Wraps to the
|
||||
/// first match. Returns `true` when there was a list to navigate.
|
||||
/// Move the autocomplete highlight down one entry. Wraps to the first match.
|
||||
pub fn autocomplete_next(&mut self) -> bool {
|
||||
let len = self.autocomplete_matches().len();
|
||||
if len == 0 {
|
||||
return false;
|
||||
}
|
||||
// No explicit cursor means the top match (index 0) is highlighted,
|
||||
// so ↓ from there advances to the next entry (wrapping at the end).
|
||||
let cur = self.autocomplete_cursor.unwrap_or(0);
|
||||
let next = if cur + 1 < len { cur + 1 } else { 0 };
|
||||
self.autocomplete_cursor = Some(next);
|
||||
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
|
||||
self.autocomplete_cursor = Some(if current + 1 < len {
|
||||
current + 1
|
||||
} else {
|
||||
0
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The command name the user has currently highlighted in the
|
||||
/// autocomplete popup, if any.
|
||||
/// The command name explicitly highlighted in the autocomplete popup.
|
||||
pub fn selected_suggestion(&self) -> Option<String> {
|
||||
let matches = self.autocomplete_matches();
|
||||
self.autocomplete_cursor
|
||||
.and_then(|i| matches.get(i).cloned())
|
||||
.and_then(|index| matches.get(index).cloned())
|
||||
}
|
||||
|
||||
/// Autocomplete suggestions for the current input — see
|
||||
|
|
@ -582,13 +588,10 @@ impl CommandLine {
|
|||
if matches.is_empty() {
|
||||
container(column![]).height(0).into()
|
||||
} else {
|
||||
// Before any arrow-key navigation, the top match is
|
||||
// highlighted so it's visible that Enter runs it — the
|
||||
// standard DWG command-line behavior.
|
||||
let cursor = self.autocomplete_cursor.unwrap_or(0);
|
||||
let mut col = column![].spacing(0).width(Length::Fill);
|
||||
for (idx, cmd) in matches.iter().enumerate() {
|
||||
let is_selected = cursor == idx;
|
||||
let is_selected = idx == cursor;
|
||||
let row = button(text(cmd.clone()).size(11))
|
||||
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
||||
.width(Length::Fill)
|
||||
|
|
@ -965,4 +968,40 @@ mod tests {
|
|||
line.push_error_once(t!("Unable to save: file is in use.").as_ref());
|
||||
assert_eq!(line.history.len(), initial_len + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_recall_walks_both_directions_and_restores_the_draft() {
|
||||
let mut line = CommandLine::new();
|
||||
for command in ["LINE", "LINE", "CIRCLE"] {
|
||||
line.record_recent(command);
|
||||
}
|
||||
line.input = "PARTIAL".to_string();
|
||||
|
||||
line.history_prev();
|
||||
assert_eq!(line.input, "CIRCLE");
|
||||
line.history_prev();
|
||||
assert_eq!(line.input, "LINE");
|
||||
line.history_prev();
|
||||
assert_eq!(line.input, "LINE");
|
||||
line.history_next();
|
||||
assert_eq!(line.input, "CIRCLE");
|
||||
line.history_next();
|
||||
assert_eq!(line.input, "PARTIAL");
|
||||
assert_eq!(line.cmd_recall, vec!["LINE".to_string(), "CIRCLE".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recalled_command_can_be_edited_before_submit() {
|
||||
let mut line = CommandLine::new();
|
||||
line.record_recent("MOVE");
|
||||
line.history_prev();
|
||||
line.input.push_str(" 0,0 10,0");
|
||||
let submitted = line.submit().expect("edited command");
|
||||
assert_eq!(submitted, "MOVE 0,0 10,0");
|
||||
line.record_recent(&submitted);
|
||||
assert_eq!(
|
||||
line.cmd_recall.last().map(String::as_str),
|
||||
Some("MOVE 0,0 10,0")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue