Shortcuts: command search can move off S, and S can go to another command (#12190) (#13163)

* claim: #12190

* Shortcuts: command search can move off S, and S can go to another command (#12190)

Command search becomes the ordinary command ui.palette with S as its default
key, so Preferences > Shortcuts lists it and can move it; S leaves the
reserved keys and the hard-wired S branch in the toolbar goes. The search
field's key cap shows command search's current key, or none when it has
none. Out of the box nothing changes: S still opens command search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
0rdnungsamt
2026-11-09 00:14:25 +00:00
committed by GitHub
parent 956fe0070b
commit 3af51db557
11 changed files with 109 additions and 27 deletions
@@ -0,0 +1,10 @@
{
"en": { "cmd.ui.palette": "Search Commands" },
"de": { "cmd.ui.palette": "Befehle suchen" },
"ar": { "cmd.ui.palette": "البحث في الأوامر" },
"he": { "cmd.ui.palette": "חיפוש פקודות" },
"ja": { "cmd.ui.palette": "コマンドを検索" },
"ko": { "cmd.ui.palette": "명령 검색" },
"vi": { "cmd.ui.palette": "Tìm kiếm lệnh" },
"zh": { "cmd.ui.palette": "搜索命令" }
}
+16
View File
@@ -30,6 +30,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ASSISTANT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
CommandSpec::new("ui.palette", "Search Commands", crate::checked!("ui.palette", palette))
.icon("zoom")
.key("S")
.noundo()
.doc_only()
.contract(&PALETTE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::PaletteResult>)
.params("Open command search in the app. Headless calls are refused without changing the design"),
CommandSpec::new("edit.undo", "Undo", crate::checked!("edit.undo", undo))
.icon("undo")
.key("Ctrl+Z")
@@ -257,6 +265,14 @@ fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
}
const PALETTE_CONTRACT: crate::schema::TypedContract<crate::schema::edit::PaletteParams> = crate::schema::TypedContract::new();
/// Command search (#12190) is the app's: it opens in `SolveApp::start`, and its key can be moved
/// in Preferences › Shortcuts like any other command's.
fn palette(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.palette opens command search in the app; there is no search box in a headless session".into()))
}
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
+6
View File
@@ -1687,6 +1687,12 @@ fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.assistant").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
// Command search likewise (#12190): S is its default key, not a reserved one.
let error = s.execute("ui.palette", &json!({})).unwrap_err().to_string();
assert!(error.contains("headless session"), "{error}");
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.palette").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("S"), "", false));
}
#[test]
+11 -1
View File
@@ -19,6 +19,15 @@ pub struct AssistantParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssistantResult {}
/// Command search is UI-owned and takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PaletteParams {}
/// Headless sessions refuse to open command search and produce no result.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaletteResult {}
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1519,7 +1528,7 @@ mod tests {
use super::{
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
@@ -1537,6 +1546,7 @@ mod tests {
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepeatParams>(), &[]);
assert_keys(input_schema_for::<AssistantParams>(), &[]);
assert_keys(input_schema_for::<PaletteParams>(), &[]);
assert_keys(input_schema_for::<UndoParams>(), &[]);
assert_keys(input_schema_for::<RedoParams>(), &[]);
assert_keys(input_schema_for::<CheckpointParams>(), &[]);
+37 -1
View File
@@ -17,7 +17,6 @@ pub(crate) const DRAG_ZOOM: &str = "view.tool.zoom";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
"Escape",
"S",
"V",
"F2",
"F6",
@@ -1884,6 +1883,43 @@ mod tests {
h.frames(1);
assert!(h.app.ui.palette_open && h.app.dialog.is_none(), "typing in a field never repeats");
}
/// Command search is an ordinary command (#12190): S opens it out of the box, Preferences ›
/// Shortcuts can move it, and S is then free for another command.
#[test]
fn command_search_can_move_off_s_and_s_can_start_another_command() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.frames(2);
assert_eq!(effective(&h.app, "ui.palette").as_deref(), Some("S"));
h.step(&json!({"key": "S"})).unwrap();
assert!(h.app.ui.palette_open, "S opens command search by default");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
assert!(!h.app.ui.palette_open, "S no longer opens command search");
h.step(&json!({"key": "J", "shift": true})).unwrap();
assert!(h.app.ui.palette_open, "its new key does");
h.call("ui.palette", json!({"open": false}));
h.frames(2);
// The closed field shows the key command search has now, and no key once it has none.
let width = || crate::scenario::rect_of("ribbon:search").map(|r| r.width());
let shown = display_for(&h.app, "ui.palette");
assert_eq!(shown.as_deref(), Some("Shift+J"));
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, shown.as_deref())));
assert!(width() > Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S"))), "not the old S");
bind(&mut h.app, "ui.palette", "", false).unwrap();
h.frames(2);
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, None)));
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
h.frames(2);
bind(&mut h.app, "solid.box", "S", true).unwrap();
h.step(&json!({"key": "S"})).unwrap();
h.frames(1);
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.box"), "S starts the command given it");
assert!(!h.app.ui.palette_open);
}
}
#[cfg(test)]
+3 -1
View File
@@ -455,6 +455,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui_kit::scope(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let invalid = typed(&query).is_some_and(|(_, result)| result.is_err());
let key = crate::keymap::display_for(app, "ui.palette");
let field = ui_kit::CommandSearch::field(
ui,
&mut query,
@@ -466,6 +467,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
progress,
radius: ui_kit::CommandSearch::field_radius(progress, ui_kit::Container::new(ctx, ui_kit::Surface::Chrome).floating()),
},
key.as_deref(),
MAX_QUERY,
);
if !open && progress == 0.0 {
@@ -1096,7 +1098,7 @@ mod tests {
let toolbar = crate::scenario::rect_of("toolbar").unwrap();
assert_eq!(crate::toolbar::TAB_ROW_HEIGHT, 30.0);
assert_eq!(crate::toolbar::RIBBON_HEIGHT, 117.0);
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx), 22.0));
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), 22.0));
assert_eq!(closed.right(), toolbar.right() - 4.0);
assert_eq!(closed.top(), toolbar.top() + 4.0);
assert_eq!(closed.center().y, toolbar.top() + 15.0);
+7 -9
View File
@@ -275,9 +275,9 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32, vers
/// The closed search field in the application bar, ending `right`: whole when the document tabs
/// keep the room Report a bug's label leaves them (`tabs_room`: theirs without the field), else
/// just its magnifier.
fn header_search_rect(ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
fn header_search_rect(app: &SolveApp, ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
let height = 26.0;
let full = crate::ui_kit::CommandSearch::closed_width(ctx);
let full = crate::ui_kit::CommandSearch::closed_width(ctx, crate::keymap::display_for(app, "ui.palette").as_deref());
let whole = bar.width() >= 640.0 && tabs_room - full - HEADER_SEARCH_GAP >= 216.0;
let width = if whole { full } else { crate::ui_kit::CommandSearch::compact_width(height) };
Rect::from_min_size(pos2(right - width, bar.center().y - height / 2.0), vec2(width, height))
@@ -536,7 +536,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
// moves the assistant button and the end of the document tabs left by its width.
let gear_left = report.left() - 68.0;
let search = (search_in_header(app) && design_shown(app))
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
.then(|| header_search_rect(app, ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
if let Some(search) = search {
// shortcut_box::show draws the one search editor wherever this rectangle is.
@@ -1385,7 +1385,8 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) -> Option<bool>
// The application bar may hold the search field instead ([`HeaderLayout`]): the tabs then
// run to the row's end.
let in_ribbon = !search_in_header(app);
let search_width = if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx()) } else { 0.0 };
let search_width =
if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx(), crate::keymap::display_for(app, "ui.palette").as_deref()) } else { 0.0 };
// The first tab and the search field sit the same distance from the card's top, left and right
// edges, so their outer corners are concentric with the card's corners.
let floating = Container::for_layout(app.ui.layout, Surface::Panel).floating();
@@ -3742,9 +3743,6 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
// A single Esc with nothing left to cancel keeps the sketch open (#1804); a quick
// second press finishes it (above).
}
Key::S if !mods.shift && !mods.alt => {
app.start("ui.palette");
}
// With a menu open, V runs that menu's Show/Hide on what the menu is for.
Key::V => {
if !crate::context_menu::press_shortcut(app, "V") {
@@ -4474,7 +4472,7 @@ mod header_report_tests {
let search = rect("ribbon:search");
assert!(search.top() >= APP_BAR_HEIGHT, "search is in the ribbon: {search:?}");
assert_eq!(search.height(), TAB_ROW_HEIGHT - 2.0 * RIBBON_TAB_INSET);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx));
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")));
assert!(crate::scenario::rect_of("titlebar:search").is_none());
let report = rect("titlebar:report");
// A pre-release's longer version takes more room than RIGHT_GROUP and moves the button left.
@@ -4507,7 +4505,7 @@ mod header_report_tests {
let (search, prefs) = (rect("titlebar:search"), rect("titlebar:preferences"));
assert_eq!(search.right(), prefs.left() - HEADER_SEARCH_GAP, "search sits immediately left of Settings");
assert!(search.bottom() <= APP_BAR_HEIGHT && (search.center().y - prefs.center().y).abs() < 0.5);
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx), "the whole field where there is room");
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), "the whole field where there is room");
assert_eq!(rect("ribbon:search"), search, "the one search field is drawn in the bar");
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
let report = rect("titlebar:report");
+1
View File
@@ -1145,6 +1145,7 @@ const ELEMENTS: &[Element] = &[
radius: CommandSearch::field_radius(1.0, true),
progress: 1.0,
},
Some("S"),
4096,
);
ui.add_space(Tokens::get().space_6);
+16 -14
View File
@@ -51,20 +51,23 @@ impl CommandSearch {
}
/// Reserve the translated hint at the exact editor font, plus compact controls.
/// The small text allowance covers pixel rounding and the editor's caret.
pub(crate) fn closed_width(ctx: &egui::Context) -> f32 {
/// The small text allowance covers pixel rounding and the editor's caret. `key` is command
/// search's own key (#12190): its cap, or none when the user took it away.
pub(crate) fn closed_width(ctx: &egui::Context, key: Option<&str>) -> f32 {
let t = Tokens::get();
let spacing = FieldSpacing::at(0.0);
let (hint, key) = ctx.fonts_mut(|fonts| {
let (hint, cap) = ctx.fonts_mut(|fonts| {
(
fonts
.layout_no_wrap(solidcraft_ui_text::tr_label("Search commands").to_owned(), FontId::proportional(t.font_button), t.form_dim)
.size()
.x,
fonts.layout_no_wrap("S".to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x,
key.map_or(0.0, |key| {
fonts.layout_no_wrap(key.to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x + spacing.key_gap + spacing.key_padding
}),
)
});
(spacing.left + t.space_16 + spacing.icon_gap + hint + spacing.key_gap + key + spacing.key_padding + spacing.right + t.space_2).ceil()
(spacing.left + t.space_16 + spacing.icon_gap + hint + cap + spacing.right + t.space_2).ceil()
}
/// The closed field as just its magnifier, for a bar short of room: a square button.
@@ -92,6 +95,7 @@ impl CommandSearch {
focus: bool,
open: bool,
geometry: SearchFieldGeometry,
key: Option<&str>,
limit: usize,
) -> Response {
let t = Tokens::get();
@@ -124,7 +128,7 @@ impl CommandSearch {
bevel.paint(ui.painter(), r.shrink(if focused || invalid { 1.0 } else { 0.0 }), radius, true, ui.is_enabled());
}
// Closed with less room than its hint takes (a narrow bar): just the magnifier.
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx()) {
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx(), key) {
icons::paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(t.space_16, t.space_16)), "zoom", 1.0);
solidcraft_ui_registry::publish_target("ribbon:search", r);
solidcraft_ui_registry::publish_target("shortcut:search", r);
@@ -139,13 +143,11 @@ impl CommandSearch {
"zoom",
1.0,
);
let cap = if open { "Esc" } else { "S" };
let key_width = Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding);
let cap = if open { Some("Esc") } else { key };
let key_width = cap
.map_or(0.0, |cap| Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding) + spacing.key_gap);
let editor_left = (r.left() + spacing.left + t.space_16 + spacing.icon_gap).min(r.right());
let editor = Rect::from_min_max(
pos2(editor_left, r.top()),
pos2((r.right() - spacing.right - key_width - spacing.key_gap).max(editor_left), r.bottom()),
);
let editor = Rect::from_min_max(pos2(editor_left, r.top()), pos2((r.right() - spacing.right - key_width).max(editor_left), r.bottom()));
let mono = value.contains('{');
let response = ui
.scope_builder(egui::UiBuilder::new().max_rect(editor), |ui| {
@@ -389,7 +391,7 @@ mod tests {
let ctx = crate::fonts::test_context();
let mut query = String::new();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
});
output.textures_delta.clear();
let shapes: Vec<_> = output
@@ -423,7 +425,7 @@ mod tests {
let ctx = crate::fonts::test_context();
let mut query = String::new();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
});
output.textures_delta.clear();
for shape in output.shapes {
+1 -1
View File
@@ -237,7 +237,7 @@ impl StageBar {
result.stage = Some(index);
}
}
let search_width = super::CommandSearch::closed_width(ui.ctx()).max(188.0);
let search_width = super::CommandSearch::closed_width(ui.ctx(), Some("S")).max(188.0);
let action_width = actions.iter().map(|a| super::actions::icon_label_width(ui, a.label, a.icon, a.primary)).sum::<f32>();
ui.add_space((ui.available_width() - search_width - action_width - t.space_4 * (actions.len() + 2) as f32).max(t.space_8));
for (index, item) in actions.iter().enumerate() {
+1
View File
@@ -330,6 +330,7 @@ timeline.play plays the history back in the view: no new geo
timeline.rename a feature name
timeline.rename_group a group name
ui.assistant the view: camera, layout, display
ui.palette the view: the command search; tested in crates/ui-egui/src/keymap.rs
view.ambient_occlusion the view: camera, layout, display
view.angle_readout the view: camera, layout, display
view.apply the view: camera, layout, display