forked from 1b-agent/opencadstudio-upstream
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:
1 parent
657d023fb7
commit
474bfcc7db
9 files changed
+330
-64
No files matched your search
@@ -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.
|
||||
|
||||
+104
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
-11
@@ -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
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user