Command search (F3 / Ctrl+Space), pie menus (Space: tools, Shift+Space: views), keys of your own, undo history, workspaces, select similar / loops / rings; (being made: the rest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 00:22:38 +02:00
commit 48a9dbe918
4 changed files with 629 additions and 8 deletions

View file

@ -9,11 +9,11 @@ add_library(fry_script STATIC script.cpp)
target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua)
if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main
add_library(fry3dx SHARED app.cpp icons.cpp)
add_library(fry3dx SHARED app.cpp commands.cpp icons.cpp)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks
set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns)
add_executable(fry3dx MACOSX_BUNDLE app.cpp icons.cpp ${icns})
add_executable(fry3dx MACOSX_BUNDLE app.cpp commands.cpp icons.cpp ${icns})
set_source_files_properties(${icns} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME Fry3DX MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/cmake/Info.plist.in)
# Vulkan on a Mac is MoltenVK (Vulkan on Metal): FRY_MOLTENVK=/path/to/libMoltenVK.dylib, or
@ -28,7 +28,7 @@ elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK i
message(WARNING "No MoltenVK found (set FRY_MOLTENVK): Fry3DX.app will only have OpenGL 4.1")
endif()
else()
add_executable(fry3dx app.cpp icons.cpp)
add_executable(fry3dx app.cpp commands.cpp icons.cpp)
endif()
if(WIN32) # a window program (no console window), with its icon
target_sources(fry3dx PRIVATE fry3dx.rc)

View file

@ -3,6 +3,7 @@
// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui.
//
// fry3dx [file.fry]
#include "commands.h"
#include "icons.h"
#include "mesh.h"
#include "scene.h"
@ -584,6 +585,15 @@ public:
self->changedShape(*o, true);
} else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_;
}, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z");
static keel::Cmd search("fry_search", [](const keel::Args& a) { // fry_search [words]: the command search, typed in
self->openSearch();
if (a.size() > 1) std::snprintf(self->search_.text, sizeof self->search_.text, "%s", a[1].c_str());
}, "Fry3DX: open the command search (F3), with words typed in");
static keel::Cmd runCmd("fry_command", [](const keel::Args& a) { if (a.size() > 1) self->buildCommands(), self->runCommand(a[1]); }, "Fry3DX: run a command by its id (file.save, view.top...)");
static keel::Cmd pie("fry_pie", [](const keel::Args& a) {
self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true;
self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2);
}, "Fry3DX: show a pie menu (tools | views)");
static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window");
static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) {
if (a.size() > 1 && a[1] == "reload") self->loadPlugins();
@ -608,6 +618,8 @@ public:
newScene();
loadOwnMaterials();
loadPlugins();
loadMyKeys();
buildCommands();
if (!startPath_.empty()) openFile(startPath_);
findAutosaves();
if (startPath_.empty()) resumeWork();
@ -682,6 +694,7 @@ public:
takeDialogResult();
ImGuiIO& io = ImGui::GetIO();
if (touch()) touchFrame(io);
if (!pendingLayout_.empty()) ImGui::LoadIniSettingsFromMemory(pendingLayout_.data(), pendingLayout_.size()), pendingLayout_.clear(); // (a workspace: before any panel)
const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode);
if (touch() && (io.DisplaySize.y > io.DisplaySize.x) != portrait_) portrait_ = !portrait_, layoutDone_ = false; // (the phone turned: its layout)
if (!layoutDone_) defaultLayout(dock);
@ -702,6 +715,8 @@ public:
if (showScript_) scriptPanel();
if (showHelp_) helpWindow();
if (showAbout_) aboutWindow();
if (showHistory_) historyWindow();
if (showWorkspaces_) workspacesWindow();
if (showPlugins_) pluginsWindow();
commandForm();
copiesWindow();
@ -709,7 +724,9 @@ public:
pushWindow();
saveQuestion();
recoverWindow();
if (!askSave_ && !showRecover_) shortcuts(io);
commandSearch();
if (!askSave_ && !showRecover_ && !search_.open) shortcuts(io);
if (pie_) return draw3D(r), overlays(), pieFrame(io);
viewport(io);
draw3D(r);
overlays();
@ -727,6 +744,8 @@ private:
Mode mode;
std::set<int> verts, faces;
std::set<std::pair<int, int>> edges;
std::string label; // (what was done after it: the message said then)
double at = 0;
};
std::vector<Snapshot> undo_, redo_;
std::vector<Snapshot> redoKept_; // (what redo had before the last undo point: back if that step is dropped)
@ -967,6 +986,20 @@ private:
};
std::vector<PluginEntry> plugins_;
bool showPlugins_ = false;
// ── Commands: found by typing, on keys of your own, round the mouse ──
std::vector<fry::Command> commands_;
fry::CommandSearch search_;
std::vector<std::string> recentCommands_;
std::map<std::string, fry::Chord> myKeys_; // (a command's id: the key you gave it)
int pie_ = 0; // 1: the tools round the mouse (Space held), 2: the views (Shift+Space)
ImVec2 pieAt_{0, 0};
bool pieStays_ = false; // (opened from the menu or console: until a click)
std::string settingKey_;
bool showHistory_ = false;
// ── Workspaces: the panels as they were, kept under a name ──
bool showWorkspaces_ = false, layoutAgain_ = false;
std::string pendingLayout_;
char workspaceName_[64] = "My workspace"; // (the command waiting for its new key, in Keys and mouse)
// A plug-in's command waiting for its settings (the form), and a material being named.
struct PendingCommand {
size_t plugin = 0, command = 0;
@ -1009,11 +1042,12 @@ private:
void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); }
void say(std::string text) {
message_ = std::move(text), messageAt_ = now();
if (!undo_.empty() && undo_.back().label.empty() && now() - undo_.back().at < 1.0) undo_.back().label = message_; // (names the step just done)
keel::log::info("{}", message_);
}
void pushUndo() {
undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_});
undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, "", now()});
if (undo_.size() > 200) undo_.erase(undo_.begin());
redoKept_ = std::move(redo_), redo_.clear();
dirty_ = true, ++edits_;
@ -1041,14 +1075,14 @@ private:
}
void undo() {
if (undo_.empty()) return say("Nothing to undo");
redo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_});
redo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, undo_.back().label, now()}); // (its name: the step it brings back)
restore(undo_.back());
undo_.pop_back();
redoKept_.clear();
}
void redo() {
if (redo_.empty()) return say("Nothing to redo");
undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_});
undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, redo_.back().label, now() - 10});
restore(redo_.back());
redo_.pop_back();
redoKept_.clear();
@ -1651,6 +1685,48 @@ private:
if (!border.count(v)) keep.insert(v);
selectPoints(keep);
}
// Like the picked: faces that face the same way, edges as long, points with as many edges.
void selectSimilar() {
Object* o = editing();
if (!o) return;
const Mesh& m = o->mesh;
if (elem_ == Elem::Face) {
std::vector<glm::vec3> ns;
for (int f : selFaces_) ns.push_back(faceNormal(m, f));
if (ns.empty()) return say("Pick a face first");
for (size_t f = 0; f < m.faces.size(); ++f)
for (const glm::vec3& n : ns)
if (glm::dot(faceNormal(m, int(f)), n) > 0.996f) selFaces_.insert(int(f));
} else if (elem_ == Elem::Edge) {
std::vector<float> lens;
for (auto [a, b] : selEdges_) lens.push_back(glm::distance(m.verts[size_t(a)], m.verts[size_t(b)]));
if (lens.empty()) return say("Pick an edge first");
for (auto [a, b] : edges(m)) {
const float l = glm::distance(m.verts[size_t(a)], m.verts[size_t(b)]);
for (float k : lens)
if (std::abs(l - k) <= k * 0.02f + 1e-6f) selEdges_.insert({a, b});
}
} else {
std::vector<int> count(m.verts.size(), 0);
for (auto [a, b] : edges(m)) ++count[size_t(a)], ++count[size_t(b)];
std::set<int> want;
for (int v : selVerts_) want.insert(count[size_t(v)]);
if (want.empty()) return say("Pick a point first");
for (size_t v = 0; v < m.verts.size(); ++v)
if (want.count(count[size_t(v)])) selVerts_.insert(int(v));
}
say("Picked the similar ones");
}
// The picked edges' whole loops (or the rings across them).
void loopsOfSelected(bool ring) {
Object* o = editing();
if (!o) return;
if (elem_ != Elem::Edge || selEdges_.empty()) return say("Pick an edge or two first (Edges, 2)");
std::set<std::pair<int, int>> add;
for (auto e : selEdges_)
for (auto f : ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e)) add.insert(f);
selEdges_.insert(add.begin(), add.end());
}
void selectLinked() {
if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints()));
}
@ -4776,6 +4852,13 @@ private:
if (io.WantTextInput) return;
const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt;
auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); };
if (key(ImGuiKey_F3) || (ctrl && key(ImGuiKey_Space))) return openSearch();
if (!settingKey_.empty()) return; // (Keys and mouse is waiting for a key)
if (const auto chord = fry::pressedChord())
for (const auto& [id, c] : myKeys_)
if (c == *chord) return void(runCommand(id));
if (key(ImGuiKey_Space) && !ctrl && !alt && mouseInView(io) && (tab_ == Tab::Model || tab_ == Tab::Render || tab_ == Tab::Print))
return void((pie_ = shift ? 2 : 1, pieAt_ = io.MousePos));
if (key(ImGuiKey_F5)) return runScriptNow();
if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model);
if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint);
@ -5439,7 +5522,7 @@ private:
void defaultLayout(ImGuiID dock) {
layoutDone_ = true;
ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock);
if (!touch() && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini)
if (!touch() && !std::exchange(layoutAgain_, false) && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini)
ImGui::DockBuilderRemoveNodeChildNodes(dock);
ImGui::DockBuilderSetNodeSize(dock, ImGui::GetMainViewport()->WorkSize); // (splits are shares of this)
ImGuiID center = dock;
@ -5483,6 +5566,180 @@ private:
ImGui::DockBuilderFinish(dock);
}
// ───────────────────────────── commands ─────────────────────────────
// Everything that can be done, by name: for the command search, keys of your own, the pies.
void buildCommands() {
commands_.clear();
auto add = [&](std::string id, std::string name, std::string group, std::string keys, std::function<void()> run, std::function<bool()> ok = {}, std::string words = {}) {
commands_.push_back({std::move(id), std::move(name), std::move(group), std::move(keys), std::move(run), std::move(ok), std::move(words)});
};
auto editing = [this] { return mode_ == Mode::Edit; };
auto picked = [this] { return !selected_.empty(); };
auto many = [this] { return selected_.size() >= 2 && mode_ == Mode::Object; };
auto model = [this] { return tab_ == Tab::Model; };
add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start");
add("file.open", "Open...", "File", "Ctrl+O", [this] { whenSaved(3); }, {}, "load fry");
add("file.import", "Import a model (STL, OBJ, 3MF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf");
add("file.save", "Save", "File", "Ctrl+S", [this] { path_.empty() ? dialog(2) : void(saveFile(path_)); });
add("file.saveas", "Save as...", "File", "Ctrl+Shift+S", [this] { dialog(2); });
add("file.export", "Export (GLB, glTF, OBJ, STL, PLY, 3MF, FBX)...", "File", "Ctrl+E", [this] { dialog(3); }, {}, "stl obj gltf glb ply fbx 3mf print");
add("file.picture", "Save a picture of the view...", "File", "F12", [this] { dialog(5); }, {}, "screenshot render png");
add("file.recover", "Recover autosaved work...", "File", "", [this] { findAutosaves(), showRecover_ = !recover_.empty(); });
add("file.quit", "Quit", "File", "Ctrl+Q", [this] { whenSaved(1); }, {}, "exit close");
add("edit.undo", "Undo", "Edit", "Ctrl+Z", [this] { undo(); }, [this] { return !undo_.empty(); });
add("edit.redo", "Redo", "Edit", "Ctrl+Y", [this] { redo(); }, [this] { return !redo_.empty(); });
add("edit.history", "Undo history", "Edit", "", [this] { showHistory_ = true; }, {}, "steps back");
add("edit.duplicate", "Duplicate", "Edit", "Ctrl+D", [this] { duplicateSelected(); }, [=, this] { return picked() && mode_ == Mode::Object; }, "copy clone");
add("edit.join", "Join into one", "Edit", "Ctrl+J", [this] { joinSelected(); }, many, "merge objects");
add("edit.delete", "Delete", "Edit", "Del", [this] { deleteSelected(); }, {}, "remove erase");
add("edit.shape", "Edit the shape / stop", "Edit", "Tab", [this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }, [=, this] { return editing() || picked(); }, "edit mode points faces");
add("select.all", "Select all", "Select", "Ctrl+A", [this] { selectAll(); });
add("select.none", "Select none", "Select", "Alt+A", [this] { selectNone(); }, {}, "deselect");
add("select.invert", "Invert the selection", "Select", "Ctrl+I", [this] { invertSelection(); });
add("select.grow", "Select more (grow)", "Select", "Ctrl +", [this] { growSelection(); }, editing);
add("select.shrink", "Select less (shrink)", "Select", "Ctrl -", [this] { shrinkSelection(); }, editing);
add("select.linked", "Select everything joined to it", "Select", "L", [this] { selectLinked(); }, editing, "linked connected");
add("select.similar", "Select similar faces (facing the same way)", "Select", "Shift+G", [this] { selectSimilar(); }, editing, "same normal size");
add("select.loop", "Select whole loops (of the picked edges)", "Select", "", [this] { loopsOfSelected(false); }, editing, "edge loop");
add("select.ring", "Select rings (across the picked edges)", "Select", "", [this] { loopsOfSelected(true); }, editing, "edge ring");
for (const auto& [t, n] : std::initializer_list<std::pair<SelShape, const char*>>{{SelShape::Box, "box"}, {SelShape::Brush, "brush"}, {SelShape::Lasso, "lasso"}})
add(std::string("select.with.") + n, std::string("Select with a ") + n, "Select", "", [this, t] { selShape_ = t; });
for (const ShapeRecipe& r : shapeRecipes())
add(std::string("add.") + r.id, std::string("Add ") + (r.starter ? "a starter " : "a ") + r.name, "Add", "", [this, id = std::string(r.id)] { setTab(Tab::Model), addShape(id); }, {},
r.starter ? "starter model" : "shape primitive");
add("add.text", "Add text (3D letters)...", "Add", "", [this] { setTab(Tab::Model), showText_ = true; }, {}, "font letters words label name");
add("add.draw", "Draw a shape", "Add", "D", [this] { if (mode_ == Mode::Edit) leaveEdit(); setTool(Tool::Draw); }, {}, "sketch outline polygon");
add("add.picture", "Add a reference picture...", "Add", "", [this] { dialog(4); }, {}, "image background blueprint");
add("object.goto", "Go to it (the camera)", "Object", "F", [this] { goTo(selected_); }, picked, "frame focus");
add("object.copies", "Make copies (in a row, round, along a path)...", "Object", "", [this] { showCopies_ = true; }, picked, "array repeat pattern circular curve");
add("object.group", "Group into a folder", "Object", "Ctrl+G", [this] { groupSelected(); }, picked);
add("object.add", "Add them together", "Object", "Ctrl+Shift++", [this] { combineSelected(Combine::Add); }, many, "boolean union");
add("object.subtract", "Take the others out of it", "Object", "Ctrl+Shift+-", [this] { combineSelected(Combine::Subtract); }, many, "boolean difference cut");
add("object.overlap", "Keep only where they overlap", "Object", "", [this] { combineSelected(Combine::Overlap); }, many, "boolean intersect");
add("object.thicken", "Make it thick (or hollow)...", "Object", "", [this] { showThicken_ = true; }, picked, "shell solidify wall");
add("object.cleanflat", "Join flat faces side by side", "Object", "", [this] { cleanFlatFaces(); }, picked, "dissolve tidy");
add("object.spin", "Spin (turn its outline round an axis)...", "Object", "", [this] { showSpin_ = true; }, [this] { return !selected_.empty(); }, "lathe screw revolve");
add("object.repair", "Repair for printing (close holes, fix faces)", "Print", "", [this] { repairSelected(); }, picked, "fix manifold watertight holes");
add("object.scale", "Scale to a size (mm)...", "Print", "", [this] { setTab(Tab::Print), showPrintSize_ = true; }, picked, "resize fit dimensions percent scaler");
for (const ToolInfo& t : kTools)
add(std::string("tool.") + t.name, std::string("Tool: ") + t.name, "Tools", t.key, [this, tool = t.tool, obj = t.objectMode] {
if (tab_ != Tab::Model) setTab(Tab::Model);
if (!obj && mode_ != Mode::Edit) enterEdit();
setTool(tool);
});
add("shape.subdivide", "Cut every face in four", "Shape", "", [this] { subdivideNow(false); }, editing, "subdivide");
add("shape.smoothall", "Smooth it all (cut and round)", "Shape", "", [this] { subdivideNow(true); }, editing, "subdivision surface");
add("shape.merge", "Merge points", "Shape", "M", [this] { mergeNow(); }, editing, "weld collapse");
add("shape.face", "Make a face", "Shape", "", [this] { makeFaceNow(); }, editing, "fill");
add("shape.flip", "Flip faces", "Shape", "", [this] { flipNow(); }, editing, "normals reverse");
add("shape.smooth", "Smooth points", "Shape", "", [this] { smoothPointsNow(); }, editing, "relax");
add("shape.push", "Push out / pull in...", "Shape", "Alt+S", [this] { startPush(); }, editing, "shrink fatten inflate");
add("shape.bridge", "Bridge two faces", "Shape", "", [this] { bridgeNow(); }, editing, "tunnel connect");
add("shape.soft", "Soft selection on / off", "Shape", "Shift+O", [this] { softSel_ = !softSel_; }, {}, "proportional falloff");
add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split");
add("shape.slide", "Slide the points along their edges...", "Shape", "", [this] { startSlide(); }, editing, "edge slide vertex slide");
add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_; }, {}, "symmetry symmetric x");
add("view.solid", "Show: solid", "View", "Z", [this] { shading_ = Shading::Solid; });
add("view.rendered", "Show: rendered (sky, sun, shadows)", "View", "Z", [this] { shading_ = Shading::Rendered; });
add("view.wire", "Show: wireframe", "View", "Shift+Z", [this] { shading_ = Shading::Wire; });
add("view.xray", "X-ray on / off", "View", "Alt+Z", [this] { xray_ = !xray_; }, {}, "see through");
add("view.grid", "Grid on / off", "View", "", [this] { showGrid_ = !showGrid_; });
add("view.persp", "View: perspective", "View", "Numpad 5", [this] { leaveFlatView(); });
for (const auto& [v, n, k] : std::initializer_list<std::tuple<View, const char*, const char*>>{
{View::Front, "front", "Numpad 1"}, {View::Back, "back", "Shift+Numpad 1"}, {View::Left, "left side", "Numpad 3"}, {View::Right, "right side", "Shift+Numpad 3"},
{View::Top, "top", "Numpad 7"}, {View::Bottom, "bottom", "Shift+Numpad 7"}})
add(std::string("view.") + n, std::string("View: ") + n, "View", k, [this, v] { setView(v); }, {}, "orthographic camera");
add("view.frame", "Frame the selected", "View", "F", [this] { frameSelected(); }, {}, "zoom focus");
add("view.all", "Frame everything", "View", "Home", [this] { frameAll(); }, {}, "zoom extents");
add("view.only", "Show only the selected (again: all)", "View", "/", [this] { showOnly(selected_); }, picked, "isolate local view hide");
add("view.unhide", "Show everything", "View", "Alt+H", [this] { for (Object& o : scene_.objects) o.visible = true; }, {}, "unhide");
add("view.layout", "Put the panels back as they were", "View", "", [this] { resetLayout(); }, {}, "reset layout workspace default panels");
add("view.workspace", "Workspaces (save the panels as they are)...", "View", "", [this] { showWorkspaces_ = true; }, {}, "layout save panels");
for (int i = 0; i < 3; ++i)
add(std::string("view.look") + char('0' + i), std::string("Appearance: ") + (i == 0 ? "like the desktop" : i == 1 ? "dark" : "light"), "View", "",
[i] { keel::execute(std::format("fry_theme {}", i)); }, {}, "theme colours");
const char* tabs[] = {"Model", "Paint", "Layout", "Render", "Print"};
for (int i = 0; i < 5; ++i)
add(std::string("tab.") + tabs[i], std::string("Go to ") + tabs[i], "Tabs", std::format("Ctrl+{}", i + 1), [this, i] { setTab(Tab(i)); }, {}, i == 1 ? "sculpt texture" : "");
add("script.panel", "The Script panel", "Script", "", [this] { showScript_ = !showScript_; }, {}, "lua code");
add("script.run", "Run the script", "Script", "F5", [this] { runScriptNow(); }, [this] { return currentScript() != nullptr; });
add("script.new", "New script", "Script", "", [this] { addScript("Script", kFirstScript); });
add("script.plugins", "Plug-ins...", "Script", "", [this] { showPlugins_ = true; }, {}, "addons extensions");
add("help.keys", "Keys and mouse (and your own keys)", "Help", "F1", [this] { showHelp_ = true; }, {}, "shortcuts hotkeys keymap bind");
add("help.about", "About Fry3DX", "Help", "", [this] { showAbout_ = true; }, {}, "version");
add("help.search", "Search for a command", "Help", "F3", [this] { openSearch(); }, {}, "find palette");
for (size_t i = 0; i < plugins_.size(); ++i)
if (pluginUsable(i))
for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) {
const fry::PluginCommand& cmd = plugins_[i].p->commands[c];
add("plugin." + plugins_[i].file + "." + cmd.name, cmd.name + (cmd.settings.empty() ? "" : "..."), "Plug-in: " + plugins_[i].p->name, "", [this, i, c] {
if (i >= plugins_.size() || !pluginUsable(i) || c >= plugins_[i].p->commands.size()) return;
const fry::PluginCommand& cm = plugins_[i].p->commands[c];
if (cm.settings.empty()) return runPluginCommand(i, c, {});
pendingCommand_ = PendingCommand{i, c, {}};
for (const auto& [n, v] : cm.settings) pendingCommand_->values.push_back(v);
}, {}, cmd.help);
}
(void)model;
}
void openSearch() {
buildCommands();
search_.show();
}
void runCommand(const std::string& id) {
for (const fry::Command& c : commands_)
if (c.id == id) {
if (!c.usable()) return say(c.name + ": not now (" + (c.group == "Shape" ? "edit a shape first, Tab" : "pick something first") + ")");
std::erase(recentCommands_, id);
recentCommands_.insert(recentCommands_.begin(), id);
if (recentCommands_.size() > 8) recentCommands_.pop_back();
return c.run();
}
}
void commandSearch() {
if (!search_.open) return;
if (const int i = search_.draw(commands_, recentCommands_); i >= 0) runCommand(commands_[size_t(i)].id);
}
// The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held.
void pieFrame(ImGuiIO& io) {
const bool released = !pieStays_ && !ImGui::IsKeyDown(ImGuiKey_Space);
std::vector<fry::PieItem> items;
std::vector<std::function<void()>> runs;
if (pie_ == 1) {
for (const ToolInfo& t : kTools)
if (mode_ == Mode::Edit ? t.editMode : t.objectMode)
items.push_back({t.name, t.icon, true, tool_ == t.tool}), runs.push_back([this, tool = t.tool] { setTool(tool); });
items.push_back({mode_ == Mode::Edit ? "Done (objects)" : "Edit the shape", mode_ == Mode::Edit ? Icon::Objects : Icon::Shape, true, false});
runs.push_back([this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); });
} else {
const std::pair<View, const char*> views[] = {{View::Top, "Top"}, {View::Right, "Right"}, {View::Front, "Front"}, {View::Perspective, "3D"},
{View::Bottom, "Bottom"}, {View::Back, "Back"}, {View::Left, "Left"}};
for (const auto& [v, n] : views) {
items.push_back({n, Icon::Select, false, view_ == v});
runs.push_back([this, v] { v == View::Perspective ? leaveFlatView() : setView(v); });
}
items.push_back({"Frame", Icon::Select, false, false}), runs.push_back([this] { frameSelected(); });
}
const int got = fry::pieMenu(pie_ == 1 ? "Tools" : "Views", pieAt_, items, released);
if (got == -1) return;
if (got >= 0) runs[size_t(got)]();
pie_ = 0, pieStays_ = false;
(void)io;
}
// Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key).
void loadMyKeys() {
myKeys_.clear();
std::ifstream in(keel::prefPath() + "keys.txt");
for (std::string line; std::getline(in, line);)
if (const size_t tab = line.find('\t'); tab != std::string::npos)
if (const auto c = fry::parseChord(line.substr(tab + 1))) myKeys_[line.substr(0, tab)] = *c;
}
void saveMyKeys() {
std::ofstream out(keel::prefPath() + "keys.txt");
for (const auto& [id, c] : myKeys_) out << id << '\t' << fry::chordName(c) << '\n';
}
void menus() {
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2);
@ -7209,6 +7466,9 @@ private:
}
ImGui::EndTable();
};
rows("Finding things", {{"F3 or Ctrl+Space", "Search for any command by name and run it"}, {"Space (held)", "The tools round the mouse: point at one, let go"},
{"Shift+Space (held)", "The views round the mouse"}});
myKeysTable();
rows("Looking around", {{"Right drag", "Orbit round what you look at"}, {"Middle drag / Shift+Right drag", "Pan"}, {"Wheel", "Zoom (towards the mouse)"},
{"Orbit / Pan / Zoom buttons", "Drag them (touchpads)"}, {"F", "Frame the selection"}, {"Home", "Frame everything"},
{"Numpad 1 / 3 / 7", "Front / side / top (Shift: the opposite)"}, {"Numpad 5", "Flat view / 3D"},
@ -7237,6 +7497,126 @@ private:
ImGui::End();
}
// Keys of your own: any command on any key (they come before the usual ones).
void myKeysTable() {
static char filter[64] = {};
if (!ImGui::CollapsingHeader("Your own keys")) return;
if (commands_.empty()) buildCommands();
ImGui::TextWrapped("Give any command a key: click Set, then press the key (with Ctrl, Shift or Alt if you like). Your keys come before the usual ones.");
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##kf", "Find a command", filter, sizeof filter);
if (!settingKey_.empty()) {
ImGui::TextColored(accentText(), "Press the key for it now (Esc: never mind)");
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) settingKey_.clear();
else if (const auto c = fry::pressedChord()) {
std::erase_if(myKeys_, [&](const auto& kv) { return kv.second == *c; }); // (a key does one thing)
myKeys_[settingKey_] = *c;
saveMyKeys();
settingKey_.clear();
}
}
if (!ImGui::BeginTable("mykeys", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, ImVec2(0, 260))) return;
ImGui::TableSetupColumn("what");
ImGui::TableSetupColumn("key", ImGuiTableColumnFlags_WidthFixed, 130);
ImGui::TableSetupColumn("do", ImGuiTableColumnFlags_WidthFixed, 96);
for (const fry::Command& c : commands_) {
if (filter[0] && !fry::commandScore(c, filter)) continue;
ImGui::PushID(c.id.c_str());
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(c.name.c_str());
ImGui::TableNextColumn();
const auto mine = myKeys_.find(c.id);
if (mine != myKeys_.end()) ImGui::TextColored(accentText(), "%s", fry::chordName(mine->second).c_str());
else ImGui::TextDisabled("%s", c.keys.c_str());
ImGui::TableNextColumn();
if (ImGui::SmallButton(settingKey_ == c.id ? "..." : "Set")) settingKey_ = c.id;
if (mine != myKeys_.end()) {
ImGui::SameLine();
if (ImGui::SmallButton("Clear")) myKeys_.erase(c.id), saveMyKeys();
}
ImGui::PopID();
}
ImGui::EndTable();
}
// ── (being made) ──
bool showText_ = false, showSpin_ = false, showPrintSize_ = false, liveMirror_ = false;
void repairSelected() {}
void startSlide() {}
std::string workspaceDir() const { return keel::prefPath() + "workspaces/"; }
void resetLayout() {
layoutAgain_ = true, layoutDone_ = false;
say("The panels are back where they started");
}
void workspacesWindow() {
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Workspaces", &showWorkspaces_, ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_AlwaysAutoResize)) return ImGui::End();
ImGui::TextWrapped("The panels as they are now, kept under a name: one for modelling, one for painting, one for a small screen...");
ImGui::SetNextItemWidth(220);
ImGui::InputText("##wname", workspaceName_, sizeof workspaceName_);
ImGui::SameLine();
if (ImGui::Button("Keep") && workspaceName_[0]) {
std::error_code ec;
stdfs::create_directories(workspaceDir(), ec);
size_t size = 0;
const char* ini = ImGui::SaveIniSettingsToMemory(&size);
std::ofstream(workspaceDir() + fileSafe(workspaceName_) + ".ini", std::ios::binary).write(ini, std::streamsize(size));
say(std::format("Workspace \"{}\" kept", workspaceName_));
}
ImGui::SeparatorText("Kept");
if (ImGui::Selectable("As it started (the usual)")) resetLayout();
std::error_code ec;
std::vector<stdfs::path> kept;
for (const auto& e : stdfs::directory_iterator(workspaceDir(), ec))
if (e.path().extension() == ".ini") kept.push_back(e.path());
std::sort(kept.begin(), kept.end());
for (const stdfs::path& f : kept) {
ImGui::PushID(f.string().c_str());
if (ImGui::Selectable(f.stem().string().c_str(), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(250, 0))) {
std::ifstream in(f, std::ios::binary);
pendingLayout_.assign(std::istreambuf_iterator<char>(in), {});
say("Workspace: " + f.stem().string());
}
ImGui::SameLine(270);
if (ImGui::SmallButton("Delete")) stdfs::remove(f, ec);
ImGui::PopID();
}
ImGui::End();
}
static std::string fileSafe(std::string n) {
for (char& c : n)
if (std::strchr("\\/:*?\"<>|", c)) c = '_';
return n;
}
// The steps that can be undone, oldest first: a click goes back (or on) to just after it.
void historyWindow() {
ImGui::SetNextWindowSize(ImVec2(340, 420), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Undo history", &showHistory_)) return ImGui::End();
int back = 0, on = 0; // (how many undos / redos the click asks for)
if (ImGui::Selectable("The start", undo_.empty())) back = int(undo_.size());
for (size_t i = 0; i < undo_.size(); ++i) {
const std::string& l = undo_[i].label;
ImGui::PushID(int(i));
if (ImGui::Selectable(l.empty() ? "A change" : l.c_str(), i + 1 == undo_.size())) back = int(undo_.size() - 1 - i);
ImGui::PopID();
}
for (size_t i = redo_.size(); i-- > 0;) { // (what redo would bring back, greyed)
ImGui::PushID(int(10000 + i));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
const std::string& l = redo_[i].label;
if (ImGui::Selectable(l.empty() ? "A change (undone)" : (l + " (undone)").c_str())) on = int(redo_.size() - i);
ImGui::PopStyleColor();
ImGui::PopID();
}
if (undo_.size() >= 200) ImGui::TextDisabled("(the 200 latest steps are kept)");
ImGui::End();
for (int i = 0; i < back; ++i) undo();
for (int i = 0; i < on; ++i) redo();
}
void aboutWindow() {
using keel::ui::Role;
const float w = 460.0f;

177
src/commands.cpp Normal file
View file

@ -0,0 +1,177 @@
#include "commands.h"
#include <imgui_internal.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <numbers>
namespace fry {
namespace {
std::string lower(std::string s) {
for (char& c : s) c = char(std::tolower(static_cast<unsigned char>(c)));
return s;
}
// The keys a chord can be made of (letters, numbers, F keys, the keypad and the rest).
bool chordKey(ImGuiKey k) {
return (k >= ImGuiKey_Tab && k <= ImGuiKey_KeypadEqual) && !(k >= ImGuiKey_LeftCtrl && k <= ImGuiKey_Menu) && k != ImGuiKey_Escape;
}
} // namespace
std::string chordName(const Chord& c) {
if (!c) return "";
std::string s;
if (c.ctrl) s += "Ctrl+";
if (c.shift) s += "Shift+";
if (c.alt) s += "Alt+";
return s + ImGui::GetKeyName(c.key);
}
std::optional<Chord> parseChord(const std::string& text) {
Chord c;
std::string rest = text;
for (;;) {
if (rest.starts_with("Ctrl+")) c.ctrl = true, rest.erase(0, 5);
else if (rest.starts_with("Shift+")) c.shift = true, rest.erase(0, 6);
else if (rest.starts_with("Alt+")) c.alt = true, rest.erase(0, 4);
else break;
}
for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k)
if (chordKey(ImGuiKey(k)) && rest == ImGui::GetKeyName(ImGuiKey(k))) {
c.key = ImGuiKey(k);
return c;
}
return std::nullopt;
}
std::optional<Chord> pressedChord() {
const ImGuiIO& io = ImGui::GetIO();
for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k)
if (chordKey(ImGuiKey(k)) && ImGui::IsKeyPressed(ImGuiKey(k), false)) return Chord{ImGuiKey(k), io.KeyCtrl, io.KeyShift, io.KeyAlt};
return std::nullopt;
}
int commandScore(const Command& c, const std::string& query) {
const std::string name = lower(c.name), all = name + " " + lower(c.group) + " " + lower(c.words) + " " + lower(c.keys);
int score = 1;
size_t from = 0;
bool any = false;
const std::string q = lower(query);
while (from < q.size()) {
const size_t space = q.find(' ', from);
const std::string word = q.substr(from, space == std::string::npos ? std::string::npos : space - from);
from = space == std::string::npos ? q.size() : space + 1;
if (word.empty()) continue;
any = true;
const size_t at = all.find(word);
if (at == std::string::npos) return 0;
const bool wordStart = at == 0 || !std::isalnum(static_cast<unsigned char>(all[at - 1]));
score += (at < name.size() ? 20 : 6) + (wordStart ? 10 : 0) + (at == 0 ? 15 : 0);
}
if (any && name == q) score += 50;
return score;
}
int CommandSearch::draw(const std::vector<Command>& all, const std::vector<std::string>& recent) {
if (!open) return -1;
const ImGuiViewport* vp = ImGui::GetMainViewport();
const float w = std::min(560.0f, vp->WorkSize.x - 32.0f);
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + vp->WorkSize.y * 0.12f), ImGuiCond_Always, ImVec2(0.5f, 0.0f));
ImGui::SetNextWindowSize(ImVec2(w, 0));
if (!ImGui::IsPopupOpen("##commands")) ImGui::OpenPopup("##commands");
int run = -1;
if (!ImGui::BeginPopup("##commands", ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings)) {
open = false;
return -1;
}
if (focus) ImGui::SetKeyboardFocusHere(), focus = false;
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##find", "Type what you want to do: round edges, hole, export STL...", text, sizeof text);
// What fits, best first (nothing typed: the ones used lately, then the rest A to Z).
std::vector<std::pair<int, int>> found; // (score, index)
const std::string q = text;
for (size_t i = 0; i < all.size(); ++i) {
int s = q.empty() ? 1 : commandScore(all[i], q);
if (!s) continue;
if (const auto r = std::find(recent.begin(), recent.end(), all[i].id); r != recent.end()) s += 40 - int(r - recent.begin()); // (used lately: higher)
if (!all[i].usable()) s -= 30;
found.push_back({s, int(i)});
}
std::stable_sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; });
if (found.size() > 60) found.resize(60);
at = std::clamp(at, 0, std::max(0, int(found.size()) - 1));
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) at = std::min(at + 1, int(found.size()) - 1);
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) at = std::max(at - 1, 0);
if (ImGui::IsItemEdited()) at = 0;
const bool enter = ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter);
const float rowH = ImGui::GetTextLineHeightWithSpacing() + 4;
ImGui::BeginChild("##list", ImVec2(0, std::min(float(found.size()), 12.0f) * rowH + 6), ImGuiChildFlags_None);
for (int n = 0; n < int(found.size()); ++n) {
const Command& c = all[size_t(found[size_t(n)].second)];
ImGui::PushID(n);
ImGui::BeginDisabled(!c.usable());
const ImVec2 p = ImGui::GetCursorScreenPos();
if (ImGui::Selectable("##c", n == at, ImGuiSelectableFlags_None, ImVec2(0, rowH - 4))) run = found[size_t(n)].second;
if (n == at && (ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsKeyPressed(ImGuiKey_UpArrow))) ImGui::SetScrollHereY();
ImDrawList* dl = ImGui::GetWindowDrawList();
const float ty = p.y + (rowH - 4 - ImGui::GetFontSize()) * 0.5f;
dl->AddText(ImVec2(p.x + 6, ty), ImGui::GetColorU32(ImGuiCol_Text), c.name.c_str());
const std::string right = c.keys.empty() ? c.group : c.group + " " + c.keys;
const float rw = ImGui::CalcTextSize(right.c_str()).x;
dl->AddText(ImVec2(p.x + ImGui::GetContentRegionAvail().x - rw - 6, ty), ImGui::GetColorU32(ImGuiCol_TextDisabled), right.c_str());
ImGui::EndDisabled();
ImGui::PopID();
}
if (found.empty()) ImGui::TextDisabled("Nothing called that");
ImGui::EndChild();
if (enter && !found.empty()) run = found[size_t(at)].second;
if (run >= 0 && !all[size_t(run)].usable()) run = -1;
if (run >= 0 || ImGui::IsKeyPressed(ImGuiKey_Escape)) open = false;
if (!open) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
return run;
}
int pieMenu(const char* title, ImVec2 c, const std::vector<PieItem>& items, bool released) {
ImDrawList* dl = ImGui::GetForegroundDrawList();
const ImGuiIO& io = ImGui::GetIO();
const int n = int(items.size());
if (!n) return -2;
const float radius = std::max(110.0f, ImGui::GetFontSize() * 8.5f), dead = 26.0f;
const ImVec2 d(io.MousePos.x - c.x, io.MousePos.y - c.y);
const float far = std::hypot(d.x, d.y);
int hot = -1;
if (far > dead) { // (the slice the mouse points into; the first at the top, going round clockwise)
const float a = std::atan2(d.x, -d.y), slice = 2 * std::numbers::pi_v<float> / float(n);
hot = int(std::floor((a + slice * 0.5f + 2 * std::numbers::pi_v<float>) / slice)) % n;
}
const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), text = ImGui::GetColorU32(ImGuiCol_Text), hi = ImGui::GetColorU32(ImGuiCol_ButtonActive),
line = ImGui::GetColorU32(ImGuiCol_Border);
dl->AddCircleFilled(c, dead, bg, 32);
dl->AddCircle(c, dead, line, 32, 1.5f);
if (far > dead) dl->AddLine(c, ImVec2(c.x + d.x / far * dead, c.y + d.y / far * dead), hi, 3.0f);
const ImVec2 ts = ImGui::CalcTextSize(title);
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y + dead + 6), ImGui::GetColorU32(ImGuiCol_TextDisabled), title);
const float h = ImGui::GetFrameHeight() + 6;
for (int i = 0; i < n; ++i) {
const float a = 2 * std::numbers::pi_v<float> * float(i) / float(n);
const ImVec2 at(c.x + std::sin(a) * radius, c.y - std::cos(a) * radius);
const float tw = ImGui::CalcTextSize(items[size_t(i)].name.c_str()).x, w = tw + (items[size_t(i)].hasIcon ? h : 0) + 20;
const ImVec2 lo(at.x - w * 0.5f, at.y - h * 0.5f), up(at.x + w * 0.5f, at.y + h * 0.5f);
const bool hover = i == hot || ImGui::IsMouseHoveringRect(lo, up, false);
if (hover) hot = i;
dl->AddRectFilled(lo, up, i == hot ? hi : bg, h * 0.5f);
dl->AddRect(lo, up, items[size_t(i)].on ? hi : line, h * 0.5f, 0, items[size_t(i)].on ? 2.0f : 1.0f);
float x = lo.x + 10;
if (items[size_t(i)].hasIcon) drawIcon(dl, items[size_t(i)].icon, ImVec2(x, lo.y + 4), h - 8, text), x += h - 2;
dl->AddText(ImVec2(x, at.y - ImGui::GetFontSize() * 0.5f), text, items[size_t(i)].name.c_str());
}
if (released || ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return hot >= 0 ? hot : -2;
return -1;
}
} // namespace fry

64
src/commands.h Normal file
View file

@ -0,0 +1,64 @@
#pragma once
// Commands: everything the app can do, by name. Found by typing (the command search, F3 or
// Ctrl+Space), put on keys of your own (Keys and mouse), and offered round the mouse (pie menus).
#include "icons.h"
#include <imgui.h>
#include <functional>
#include <optional>
#include <string>
#include <vector>
namespace fry {
struct Command {
std::string id; // (stays the same: what keys of your own are kept against)
std::string name; // what it's called ("Round edges")
std::string group; // where it lives ("Shape", "View")
std::string keys; // its own key, as shown ("B")
std::function<void()> run;
std::function<bool()> ok; // (can it be done now; none: always)
std::string words; // more to find it by ("bevel chamfer")
bool usable() const { return !ok || ok(); }
};
// A key with what's held with it.
struct Chord {
ImGuiKey key = ImGuiKey_None;
bool ctrl = false, shift = false, alt = false;
bool operator==(const Chord&) const = default;
explicit operator bool() const { return key != ImGuiKey_None; }
};
std::string chordName(const Chord& c);
std::optional<Chord> parseChord(const std::string& text);
// The chord pressed this frame (a key that isn't only Ctrl / Shift / Alt), or none.
std::optional<Chord> pressedChord();
// How well `query` fits a command (0: not at all): every word of it in the name, group or
// words; the start of a word, and the name, count most.
int commandScore(const Command& c, const std::string& query);
// The command search: a box to type in and the commands that fit, best first; Enter or a click
// runs one. `recent`: ids run lately (first when nothing's typed yet). True while it's open.
struct CommandSearch {
bool open = false;
char text[128] = {};
int at = 0;
bool focus = false;
void show() { open = true, focus = true, text[0] = 0, at = 0; }
// Draws it; the command to run (its index), or -1.
int draw(const std::vector<Command>& all, const std::vector<std::string>& recent);
};
// A pie menu: the choices round the mouse, picked by moving towards one and letting go of the
// key (or clicking). Draws it; the index picked, -1 while choosing, -2 when let go on nothing.
struct PieItem {
std::string name;
Icon icon = Icon::Select;
bool hasIcon = true;
bool on = false;
};
int pieMenu(const char* title, ImVec2 centre, const std::vector<PieItem>& items, bool released);
} // namespace fry