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:
Hakan Seven 2026-08-05 00:08:35 +03:00
commit 474bfcc7db
9 changed files with 330 additions and 64 deletions

View file

@ -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(

View file

@ -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.

View file

@ -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(&para0, &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(&para0, &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(&para0, &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;
}
}

View file

@ -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))

View file

@ -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;

View file

@ -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()
}
}

View file

@ -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 => {

View file

@ -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 ───────────────────────────────────────────────────────

View file

@ -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")
);
}
}