feat(commandline): always-on keyboard capture + space submits like enter

Two behaviour changes that bring the command line in line with
established CAD workflow:

1. **Always-on keyboard capture.** The input was blurred whenever no
   command was running, so typed letters went nowhere until the user
   clicked into the field. Replace the blur with a re-focus on every
   command lifecycle transition (cmd_result.rs) and on viewport / tab
   / ribbon clicks (update.rs), focus the input at startup, and add
   a global key subscription that catches any unhandled character key
   or backspace and routes it into the buffer via two new messages
   (CommandAppendChar / CommandBackspace) — so the command line keeps
   accumulating keystrokes even if focus parks somewhere not
   focusable. Removes the unused blur_cmd_input helper.

2. **Space submits.** Typing a space at the end of the input now
   submits the buffer the same way Enter does. Only exception: an
   active command that's currently collecting free-form prose
   (TEXT / MTEXT / DDEDIT body, ATTDEF prompt / default value,
   INSERT attribute value, ATTEDIT cell). Those advertise the new
   `CadCommand::wants_text_with_spaces()` flag so the Space character
   reaches the buffer as a literal.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-26 18:59:51 +03:00
1 parent e55b280dda
commit 39150d617f
16 files changed
+136 -42

No files matched your search

+6 -5
View File
@@ -1539,13 +1539,14 @@ impl OpenCADStudio {
self.restore_pre_cmd_tangent();
}
}
// Focus the command-line input while a command is active; blur it when the command ends.
if self.tabs[i].active_cmd.is_some() {
self.focus_cmd_input()
} else {
// 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.
if self.tabs[i].active_cmd.is_none() {
self.ribbon.deactivate_tool();
self.blur_cmd_input()
}
self.focus_cmd_input()
}
/// Restore the tangent snap state that was in effect before the command started.
+10 -1
View File
@@ -343,6 +343,14 @@ pub enum Message {
CommandInput(String),
CommandSubmit,
Command(String),
/// Append one typed character to the command-line input from the
/// global key-press subscription. Used when the text-input widget
/// itself isn't focused (focus parked on viewport / button / etc.)
/// so typing still routes to the command line.
CommandAppendChar(String),
/// Pop the trailing character off the command-line input — backspace
/// counterpart to `CommandAppendChar`.
CommandBackspace,
/// Recall previous command in history (↑ arrow key).
CommandHistoryPrev,
/// Recall next command in history (↓ arrow key).
@@ -848,7 +856,8 @@ impl OpenCADStudio {
crate::update_check::check_for_update(),
Message::UpdateCheckResult,
);
(s, Task::batch([open_main, check_update]))
let focus_cmd = s.focus_cmd_input();
(s, Task::batch([open_main, check_update, focus_cmd]))
}
}
+46 -13
View File
@@ -691,7 +691,7 @@ impl OpenCADStudio {
Message::RibbonSelectTab(idx) => {
self.ribbon.select(idx);
Task::none()
self.focus_cmd_input()
}
Message::RibbonToolClick { tool_id, event } => {
@@ -727,7 +727,7 @@ impl OpenCADStudio {
return Task::done(Message::ToggleLayers);
}
}
Task::none()
self.focus_cmd_input()
}
// ── Application menu ──────────────────────────────────────────
@@ -759,7 +759,7 @@ impl OpenCADStudio {
// fresh tab's defaults (ByLayer) instead of inheriting the
// previous tab's last selection.
self.sync_ribbon_from_selection();
Task::none()
self.focus_cmd_input()
}
Message::TabSwitch(idx) => {
@@ -772,7 +772,7 @@ impl OpenCADStudio {
// if there is one), not the prior tab's choice.
self.sync_ribbon_from_selection();
}
Task::none()
self.focus_cmd_input()
}
Message::TabClose(idx) => {
@@ -807,11 +807,40 @@ impl OpenCADStudio {
}
Message::CommandInput(s) => {
// Any manual typing resets the history recall cursor.
// Space submits the current input the same way Enter does
// (CAD convention) — unless the active command is collecting
// free-form text (TEXT / MTEXT / DDEDIT / attribute value
// prompts) where Space must reach the buffer as a literal
// character. `wants_text_with_spaces()` flags those prompts.
let i = self.active_tab;
let allow_literal_space = self
.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
.unwrap_or(false);
if !allow_literal_space && s.ends_with(' ') {
self.command_line.input = s.trim_end_matches(' ').to_string();
return Task::done(Message::CommandSubmit);
}
self.command_line.input = s;
Task::none()
}
Message::CommandAppendChar(s) => {
// Filter out control characters — only push the typed
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
if s.chars().all(|c| !c.is_control()) {
self.command_line.input.push_str(&s);
}
self.focus_cmd_input()
}
Message::CommandBackspace => {
self.command_line.input.pop();
self.focus_cmd_input()
}
Message::CommandHistoryPrev => {
self.command_line.history_prev();
Task::none()
@@ -1723,11 +1752,15 @@ impl OpenCADStudio {
Message::ViewportLeftPress => {
let i = self.active_tab;
// Clicking inside the viewport must not park focus on the
// viewport widget — typed characters need to keep flowing
// into the command-line input. Refocus on every exit path.
let refocus = self.focus_cmd_input();
let (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {
Some(p) => p,
None => return Task::none(),
None => return refocus,
};
(p, sel.vp_size)
};
@@ -1744,7 +1777,7 @@ impl OpenCADStudio {
if scene::hit_test(p.x, p.y, vw, vh, cam.view_rotation_mat(), VIEWCUBE_PX)
.is_some()
{
return Task::none();
return refocus;
}
}
@@ -1780,7 +1813,7 @@ impl OpenCADStudio {
origin_world: world,
last_world: world,
});
return Task::none();
return refocus;
}
}
}
@@ -1791,7 +1824,7 @@ impl OpenCADStudio {
sel.left_press_pos = Some(p);
sel.left_press_time = Some(Instant::now());
sel.left_dragging = false;
Task::none()
refocus
}
Message::ViewportLeftRelease => {
@@ -2443,7 +2476,7 @@ impl OpenCADStudio {
) {
return Task::done(Message::ViewCubeSnap(region));
}
Task::none()
self.focus_cmd_input()
}
Message::WindowResized(w, h) => {
@@ -3129,13 +3162,13 @@ impl OpenCADStudio {
self.ribbon.select(idx);
}
self.command_line.push_output(&format!(
"Layout \"{new_name}\" oluşturuldu — MVIEW ile viewport ekleyin"
"Layout \"{new_name}\" created — use MVIEW to add a viewport"
));
self.tabs[i].dirty = true;
}
Err(e) => self
.command_line
.push_error(&format!("Layout oluşturulamadı: {e}")),
.push_error(&format!("Failed to create layout: {e}")),
}
Task::none()
}
@@ -3187,7 +3220,7 @@ impl OpenCADStudio {
.any(|n| *n == new_name);
if exists {
self.command_line
.push_error(&format!("\"{}\" adı zaten kullanımda", new_name));
.push_error(&format!("\"{}\" name already in use", new_name));
} else {
self.push_undo_snapshot(i, "LAYOUT RENAME");
self.tabs[i].scene.rename_layout(&orig, &new_name);
+14 -5
View File
@@ -678,6 +678,11 @@ impl OpenCADStudio {
{
Some(Message::DeleteSelected)
}
keyboard::Key::Named(keyboard::key::Named::Backspace)
if status == Status::Ignored =>
{
Some(Message::CommandBackspace)
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
Some(Message::CommandHistoryPrev)
}
@@ -718,6 +723,15 @@ impl OpenCADStudio {
"v" => Some(Message::Command("PASTECLIP".to_string())),
_ => None,
},
// Plain character keys (no ctrl) that nothing
// else consumed — route them into the command
// line input so typing always reaches it even
// when focus is parked on a button / viewport.
keyboard::Key::Character(c)
if !ctrl && status == Status::Ignored =>
{
Some(Message::CommandAppendChar(c.to_string()))
}
_ => None,
}
}
@@ -730,11 +744,6 @@ 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 blur_cmd_input(&self) -> Task<Message> {
let op = iced::advanced::widget::operation::focusable::unfocus::<Message>();
iced::advanced::widget::operate(op)
}
}
// ── Paper canvas ──────────────────────────────────────────────────────────