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 committed 2026-08-05 00:08:35 +03:00
1 parent 657d023fb7
commit 474bfcc7db
9 files changed
+330 -64

No files matched your search

+9 -4
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(
+20
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.
+104 -4
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;
}
}
+1 -1
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))
+4 -5
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;
+1 -1
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()
}
}
+70 -11
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 => {
+45 -1
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 ───────────────────────────────────────────────────────