#include "EditorCommandPalette.hpp" #include "AppConfig.hpp" #include "EditorUtils.hpp" #include "FuzzyMatch.hpp" #include "Utils.hpp" #include #include #include #include #include #include #define IMGUI_DEFINE_MATH_OPERATORS #include using namespace std::literals; bool EditorCommandExecuteContext::IsInitiated() const { return mCommand != nullptr; } const EditorCommand* EditorCommandExecuteContext::GetCurrentCommand() const { return mCommand; } void EditorCommandExecuteContext::Initiate(const EditorCommand& command) { if (mCommand == nullptr) { mCommand = &command; } } void EditorCommandExecuteContext::Prompt(std::vector options) { assert(mCommand != nullptr); mCurrentOptions = std::move(options); ++mDepth; } void EditorCommandExecuteContext::Finish() { assert(mCommand != nullptr); mCommand = nullptr; mCurrentOptions.clear(); mDepth = 0; } int EditorCommandExecuteContext::GetExecutionDepth() const { return mDepth; } struct EditorCommandPalette::SearchResult { int itemIndex; int score; int matchCount; uint8_t matches[32]; }; struct EditorCommandPalette::Item { bool hovered = false; bool held = false; }; EditorCommandPalette::EditorCommandPalette() = default; EditorCommandPalette::~EditorCommandPalette() = default; namespace P6503_UNITY_ID { std::string MakeCommandName(std::string_view category, std::string_view name) { std::string result; constexpr auto infix = ": "sv; result.reserve(category.size() + infix.size() + name.size()); result.append(category); result.append(infix); result.append(name); return result; } } // namespace P6503_UNITY_ID void EditorCommandPalette::AddCommand(std::string_view category, std::string_view name, EditorCommand command) { command.name = P6503_UNITY_ID::MakeCommandName(category, name); auto location = std::lower_bound( mCommands.begin(), mCommands.end(), command, [](const EditorCommand& a, const EditorCommand& b) -> bool { return a.name < b.name; }); auto iter = mCommands.insert(location, std::move(command)); InvalidateSearchResults(); } void EditorCommandPalette::RemoveCommand(std::string_view category, std::string_view name) { auto commandName = P6503_UNITY_ID::MakeCommandName(category, name); RemoveCommand(commandName); } void EditorCommandPalette::RemoveCommand(const std::string& commandName) { struct Comparator { bool operator()(const EditorCommand& command, const std::string& str) const { return command.name < str; } bool operator()(const std::string& str, const EditorCommand& command) const { return str < command.name; } }; auto range = std::equal_range(mCommands.begin(), mCommands.end(), commandName, Comparator{}); mCommands.erase(range.first, range.second); InvalidateSearchResults(); } void EditorCommandPalette::Show(bool* open) { // Center window horizontally, align top vertically ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->Size.x / 2, 0), ImGuiCond_Always, ImVec2(0.5f, 0.0f)); ImGui::SetNextWindowSizeRelScreen(0.3f, 0.0f); ImGui::Begin("Command Palette", open, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar); float width = ImGui::GetWindowContentRegionMax().x - ImGui::GetWindowContentRegionMin().x; if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || mShouldCloseNextFrame) { // Close popup when user unfocused the command palette window (clicking elsewhere) // or some action requested closing this window mShouldCloseNextFrame = false; if (open) { *open = false; } } if (ImGui::IsWindowAppearing() || mFocusSearchBox) { mFocusSearchBox = false; // Focus the search box when user first brings command palette window up // Note: this only affects the next frame ImGui::SetKeyboardFocusHere(0); } ImGui::SetNextItemWidth(width); if (ImGui::InputText("##", &mSearchText)) { // Search string updated, update search results mFocusedItemId = 0; mSearchResults.clear(); size_t itemCount; if (mExeCtx.GetExecutionDepth() == 0) { itemCount = mCommands.size(); } else { itemCount = mExeCtx.mCurrentOptions.size(); } for (size_t i = 0; i < itemCount; ++i) { const char* text; if (mExeCtx.GetExecutionDepth() == 0) { text = mCommands[i].name.c_str(); } else { text = mExeCtx.mCurrentOptions[i].c_str(); } SearchResult result{ .itemIndex = (int)i, }; if (FuzzyMatch::Search(mSearchText.c_str(), text, result.score, result.matches, std::size(result.matches), result.matchCount)) { mSearchResults.push_back(result); } } std::sort( mSearchResults.begin(), mSearchResults.end(), [](const SearchResult& a, const SearchResult& b) -> bool { // We want the biggest element first return a.score > b.score; }); } ImGui::BeginChild("SearchResults", ImVec2(width, 300), false, ImGuiWindowFlags_AlwaysAutoResize); auto window = ImGui::GetCurrentWindow(); auto& io = ImGui::GetIO(); auto dlSharedData = ImGui::GetDrawListSharedData(); auto textColor = ImGui::GetColorU32(ImGuiCol_Text); auto itemHoveredColor = ImGui::GetColorU32(ImGuiCol_HeaderHovered); auto itemActiveColor = ImGui::GetColorU32(ImGuiCol_HeaderActive); auto itemSelectedColor = ImGui::GetColorU32(ImGuiCol_Header); int itemCount = GetItemCount(); if (mItems.size() < itemCount) { mItems.resize(itemCount); } // Flag used to delay item selection until after the loop ends bool selectFocusedItem = false; for (size_t i = 0; i < itemCount; ++i) { auto id = window->GetID(static_cast(i)); ImVec2 size{ ImGui::GetContentRegionAvail().x, dlSharedData->Font->FontSize, }; ImRect rect{ window->DC.CursorPos, window->DC.CursorPos + ImGui::CalcItemSize(size, 0.0f, 0.0f), }; bool& hovered = mItems[i].hovered; bool& held = mItems[i].held; if (held && hovered) { window->DrawList->AddRectFilled(rect.Min, rect.Max, itemActiveColor); } else if (hovered) { window->DrawList->AddRectFilled(rect.Min, rect.Max, itemHoveredColor); } else if (mFocusedItemId == i) { window->DrawList->AddRectFilled(rect.Min, rect.Max, itemSelectedColor); } auto item = GetItem(i); if (item.indexType == SearchResultIndex) { // Iterating search results: draw text with highlights at matched chars auto& searchResult = mSearchResults[i]; auto textPos = window->DC.CursorPos; int rangeBegin; int rangeEnd; int lastRangeEnd = 0; auto DrawCurrentRange = [&]() -> void { if (rangeBegin != lastRangeEnd) { // Draw normal text between last highlighted range end and current highlighted range start auto begin = item.text + lastRangeEnd; auto end = item.text + rangeBegin; window->DrawList->AddText(textPos, textColor, begin, end); auto segmentSize = dlSharedData->Font->CalcTextSizeA(dlSharedData->Font->FontSize, std::numeric_limits::max(), 0.0f, begin, end); textPos.x += segmentSize.x; } auto begin = item.text + rangeBegin; auto end = item.text + rangeEnd; window->DrawList->AddText(AppConfig::fontBold, AppConfig::fontBold->FontSize, textPos, textColor, begin, end); auto segmentSize = AppConfig::fontBold->CalcTextSizeA(AppConfig::fontBold->FontSize, std::numeric_limits::max(), 0.0f, begin, end); textPos.x += segmentSize.x; }; assert(searchResult.matchCount >= 1); rangeBegin = searchResult.matches[0]; rangeEnd = rangeBegin; int lastCharIdx = -1; for (int j = 0; j < searchResult.matchCount; ++j) { int charIdx = searchResult.matches[j]; if (charIdx == lastCharIdx + 1) { // These 2 indices are equal, extend our current range by 1 ++rangeEnd; } else { DrawCurrentRange(); lastRangeEnd = rangeEnd; rangeBegin = charIdx; rangeEnd = charIdx + 1; } lastCharIdx = charIdx; } // Draw the remaining range (if any) if (rangeBegin != rangeEnd) { DrawCurrentRange(); } // Draw the text after the last range (if any) window->DrawList->AddText(textPos, textColor, item.text + rangeEnd); // Draw until \0 } else { // Iterating everything else: draw text as-is, there is no highlights window->DrawList->AddText(window->DC.CursorPos, textColor, item.text); } ImGui::ItemSize(rect); if (!ImGui::ItemAdd(rect, id)) { continue; } if (ImGui::ButtonBehavior(rect, id, &hovered, &held)) { mFocusedItemId = i; selectFocusedItem = true; } } if (ImGui::IsKeyPressed(GLFW_KEY_UP)) { mFocusedItemId = std::max(mFocusedItemId - 1, 0); } else if (ImGui::IsKeyPressed(GLFW_KEY_DOWN)) { mFocusedItemId = std::min(mFocusedItemId + 1, itemCount - 1); } if (ImGui::IsKeyPressed(GLFW_KEY_ENTER) || selectFocusedItem) { SelectFocusedItem(); } ImGui::EndChild(); ImGui::End(); } size_t EditorCommandPalette::GetItemCount() const { int depth = mExeCtx.GetExecutionDepth(); if (depth == 0) { if (mSearchText.empty()) { return mCommands.size(); } else { return mSearchResults.size(); } } else { if (mSearchText.empty()) { return mExeCtx.mCurrentOptions.size(); } else { return mSearchResults.size(); } } } EditorCommandPalette::ItemInfo EditorCommandPalette::GetItem(size_t idx) const { ItemInfo option; int depth = mExeCtx.GetExecutionDepth(); if (depth == 0) { if (mSearchText.empty()) { option.text = mCommands[idx].name.c_str(); option.command = &mCommands[idx]; option.itemId = idx; option.indexType = DirectIndex; } else { auto id = mSearchResults[idx].itemIndex; option.text = mCommands[id].name.c_str(); option.command = &mCommands[id]; option.itemId = id; option.indexType = SearchResultIndex; } option.itemType = CommandItem; } else { assert(mExeCtx.GetCurrentCommand() != nullptr); if (mSearchText.empty()) { option.text = mExeCtx.mCurrentOptions[idx].c_str(); option.command = mExeCtx.GetCurrentCommand(); option.itemId = idx; option.indexType = DirectIndex; } else { auto id = mSearchResults[idx].itemIndex; option.text = mExeCtx.mCurrentOptions[id].c_str(); option.command = mExeCtx.GetCurrentCommand(); option.itemId = id; option.indexType = SearchResultIndex; } option.itemType = CommandOptionItem; } return option; } void EditorCommandPalette::SelectFocusedItem() { if (mFocusedItemId < 0 || mFocusedItemId >= GetItemCount()) { return; } auto selectedItem = GetItem(mFocusedItemId); auto& command = *selectedItem.command; int depth = mExeCtx.GetExecutionDepth(); if (depth == 0) { assert(!mExeCtx.IsInitiated()); mExeCtx.Initiate(*selectedItem.command); if (command.callback) { command.callback(mExeCtx); mFocusSearchBox = true; // Don't invalidate search results if no further actions have been requested (returning to global list of commands) if (mExeCtx.IsInitiated()) { InvalidateSearchResults(); } } else { mExeCtx.Finish(); } } else { assert(mExeCtx.IsInitiated()); assert(command.subsequentCallback); command.subsequentCallback(mExeCtx, selectedItem.itemId); mFocusSearchBox = true; InvalidateSearchResults(); } // This action terminated execution, close command palette window if (!mExeCtx.IsInitiated()) { if (command.terminate) { command.terminate(); } mShouldCloseNextFrame = true; } } void EditorCommandPalette::InvalidateSearchResults() { mSearchText.clear(); mSearchResults.clear(); mFocusedItemId = 0; }