mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
* 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:
@@ -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": "搜索命令" }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>(), &[]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user