aboutsummaryrefslogtreecommitdiff
path: root/ProjectBrussel/Game/EditorCommandPalette.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'ProjectBrussel/Game/EditorCommandPalette.cpp')
-rw-r--r--ProjectBrussel/Game/EditorCommandPalette.cpp406
1 files changed, 406 insertions, 0 deletions
diff --git a/ProjectBrussel/Game/EditorCommandPalette.cpp b/ProjectBrussel/Game/EditorCommandPalette.cpp
new file mode 100644
index 0000000..0e7b894
--- /dev/null
+++ b/ProjectBrussel/Game/EditorCommandPalette.cpp
@@ -0,0 +1,406 @@
+#include "EditorCommandPalette.hpp"
+
+#include "AppConfig.hpp"
+#include "EditorUtils.hpp"
+#include "FuzzyMatch.hpp"
+#include "Utils.hpp"
+
+#include <GLFW/glfw3.h>
+#include <imgui.h>
+#include <misc/cpp/imgui_stdlib.h>
+#include <algorithm>
+#include <limits>
+#include <utility>
+
+#define IMGUI_DEFINE_MATH_OPERATORS
+#include <imgui_internal.h>
+
+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<std::string> 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<int>(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<float>::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<float>::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;
+}