diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a3657874b..b3a314073 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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( diff --git a/src/app/mod.rs b/src/app/mod.rs index 004753823..667bccf35 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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. diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index 08a1d9556..c11e873ff 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -538,6 +538,63 @@ fn cells_delete_range(cells: &mut Vec, 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; } } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index e80209269..2759aa539 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -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)) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index dd0120c2d..a7d7ed7de 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -193,6 +193,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } 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 { } 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 { } 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; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b2ccc45dc..30eb93c0f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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() } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 75cdecace..0c918b4f8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d703d45ce..d9bc2021a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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 { iced::widget::operation::focus(iced::widget::Id::new(crate::ui::command_line::CMD_INPUT_ID)) } + + pub(super) fn unfocus_widgets(&self) -> Task { + iced::advanced::widget::operate( + iced::advanced::widget::operation::focusable::unfocus(), + ) + } } // ── Document tab bar ─────────────────────────────────────────────────────── diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index f933d374b..4396b0a6f 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -67,8 +67,8 @@ pub struct CommandLine { /// instead of submitting. Saved in the user config. pub literal_spaces: bool, pub history: Vec, - /// 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, /// 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, /// 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 { 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") + ); + } }