#include "window.hpp" #include #include #include #include #include #include #include "../log.hpp" #include "../util/physfs.hpp" #include "../util/preferences.hpp" namespace game::developer { bool gIsDocumentUndoMenuRequested{}; bool gIsDocumentRedoMenuRequested{}; namespace window_detail { constexpr auto RECENT_FILES_MAX = 10; constexpr auto CENTER_RATIO = 0.5f; constexpr auto DEVELOPER_WINDOW_SIZE_RATIO = 0.75f; constexpr auto DEVELOPER_WINDOW_FLAGS = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings; constexpr auto EDITOR_CHILD_FLAGS = ImGuiWindowFlags_MenuBar; constexpr auto DIALOGUE_SCHEMA_FILENAME = "dialogue.xml"; using Schema = resource::xml::Schema; using Element = Schema::Element; #define GAME_DEVELOPER_SCHEMA_FILES(X) \ X(CHARACTER, "character.xml", "Character", Element::CHARACTER) \ X(DIALOGUE, "dialogue.xml", "Dialogue", Element::DIALOGUE) \ X(ITEMS, "items.xml", "Items", Element::ITEM_SCHEMA) \ X(AREAS, "areas.xml", "Areas", Element::AREAS) \ X(MENU, "menu.xml", "Menu", Element::MENU) \ X(CURSOR, "cursor.xml", "Cursor", Element::CURSOR) \ X(PARTICLES, "particles.xml", "Particles", Element::PARTICLES) \ X(STRINGS, "strings.xml", "Strings", Element::STRINGS) struct SchemaInfo { std::string_view filename{}; std::string_view label{}; Element::Type type{}; }; constexpr SchemaInfo SCHEMA_INFOS[]{ #define GAME_DEVELOPER_SCHEMA_INFO(SYMBOL, FILENAME, LABEL, TYPE) {.filename = FILENAME, .label = LABEL, .type = TYPE}, GAME_DEVELOPER_SCHEMA_FILES(GAME_DEVELOPER_SCHEMA_INFO) #undef GAME_DEVELOPER_SCHEMA_INFO }; std::filesystem::path path_normalized_get(const std::filesystem::path& path) { std::error_code error{}; auto normalized = std::filesystem::weakly_canonical(path, error); if (!error) return normalized; return std::filesystem::absolute(path, error).lexically_normal(); } std::filesystem::path recent_file_path_get() { return util::preferences::path() / "recent.txt"; } std::string document_path_label_get(const std::filesystem::path& path) { auto filename = path.filename(); if (filename.empty()) return path.string(); return filename.string(); } bool shortcut_is_pressed(ImGuiKeyChord chord) { auto& io = ImGui::GetIO(); return !io.WantTextInput && !ImGui::IsAnyItemActive() && ImGui::Shortcut(chord, ImGuiInputFlags_RouteGlobal | ImGuiInputFlags_RouteOverFocused); } #undef GAME_DEVELOPER_SCHEMA_FILES } using namespace window_detail; void Window::recent_files_load() { if (isRecentFilesLoaded) return; isRecentFilesLoaded = true; recentFiles.clear(); std::ifstream file(recent_file_path_get()); if (!file.is_open()) return; std::string line{}; while (std::getline(file, line)) { if (line.empty()) continue; recentFiles.push_back(path_normalized_get(line)); if ((int)recentFiles.size() >= RECENT_FILES_MAX) break; } } void Window::recent_files_save() { auto recentPath = recent_file_path_get(); if (recentPath.empty()) return; std::error_code error{}; if (!recentPath.parent_path().empty()) std::filesystem::create_directories(recentPath.parent_path(), error); std::ofstream file(recentPath, std::ios::trunc); if (!file.is_open()) { logger.warning(std::format("Failed to write {}", recentPath.string())); return; } for (auto& path : recentFiles) file << path.string() << '\n'; } void Window::recent_file_push(const std::filesystem::path& path) { recent_files_load(); auto normalized = path_normalized_get(path); if (normalized.empty()) return; std::erase_if(recentFiles, [&](const auto& recent) { return recent == normalized; }); recentFiles.insert(recentFiles.begin(), normalized); if ((int)recentFiles.size() > RECENT_FILES_MAX) recentFiles.resize(RECENT_FILES_MAX); recent_files_save(); } Document Window::document_load(const std::filesystem::path& path) { Document document{}; document.path = path_normalized_get(path); for (auto& info : SCHEMA_INFOS) { Document::SchemaFile schemaFile{}; schemaFile.filename = info.filename; schemaFile.label = info.label; schemaFile.type = info.type; schemaFile.path = document.path / schemaFile.filename; schemaFile.isExists = std::filesystem::is_regular_file(schemaFile.path); if (schemaFile.isExists) { schemaFile.isValid = schemaFile.schema.load(schemaFile.path); auto* root = schemaFile.schema.root(); if (!root || root->type != schemaFile.type) schemaFile.isValid = false; } document.schemas.emplace_back(std::move(schemaFile)); } return document; } void Window::folder_open(const std::filesystem::path& path) { auto normalizedPath = path_normalized_get(path); std::error_code error{}; if (!std::filesystem::is_directory(normalizedPath, error)) { logger.warning(std::format("Ignored {}", normalizedPath.string())); return; } auto it = std::ranges::find_if(documents, [&](const auto& document) { return document.path == normalizedPath; }); if (it != documents.end()) { *it = document_load(normalizedPath); it->history_clear(); it->isDirty = false; selectedDocument = (int)std::distance(documents.begin(), it); logger.info(std::format("Opened {}", normalizedPath.string())); toasts.push(std::format("Opened {}", normalizedPath.string())); } else { documents.push_back(document_load(normalizedPath)); selectedDocument = (int)documents.size() - 1; logger.info(std::format("Opened {}", normalizedPath.string())); toasts.push(std::format("Opened {}", normalizedPath.string())); } recent_file_push(normalizedPath); } void Window::save(int index) { if (index < 0 || index >= (int)documents.size()) return; auto& document = documents[index]; auto* dialogue = document.dialogue_schema_get(); if (!dialogue || !dialogue->isExists) { toasts.push("No dialogue.xml to save"); return; } logger.info(std::format("Wrote to {}", dialogue->path.string())); dialogue->schema.serialize(dialogue->path); documents[index].isDirty = false; toasts.push(std::format("Wrote to {}", dialogue->path.string())); recent_file_push(documents[index].path); } void Window::export_to(int index, const std::filesystem::path& path) { if (index < 0 || index >= (int)documents.size()) return; if (documents[index].isDirty) save(index); auto destination = path_normalized_get(path); if (util::physfs::archive_write(documents[index].path, destination)) toasts.push(std::format("Exported {}", destination.string())); else toasts.push(std::format("Failed to export {}", destination.string())); } void Window::close(int index) { if (index < 0 || index >= (int)documents.size()) return; auto label = document_path_label_get(documents[index].path); documents.erase(documents.begin() + index); selectedDocument = documents.empty() ? 0 : std::clamp(index, 0, (int)documents.size() - 1); toasts.push(std::format("Closed {}", label)); } void Window::undo(int index) { if (index < 0 || index >= (int)documents.size()) return; auto& document = documents[index]; if (!document.can_undo()) return; auto message = document.undo_apply(); if (!message.empty()) toasts.push(std::format("Undo: {}", message)); } void Window::redo(int index) { if (index < 0 || index >= (int)documents.size()) return; auto& document = documents[index]; if (!document.can_redo()) return; auto message = document.redo_apply(); if (!message.empty()) toasts.push(std::format("Redo: {}", message)); } void Window::dropped_file_open(const char* path) { if (!path) return; folder_open(path); } void Window::editor_shortcuts_update() { if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiKey_O)) dialog.folder_open(util::Dialog::SCHEMA_FOLDER_OPEN); if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z)) redo(selectedDocument); else if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiKey_Z)) undo(selectedDocument); if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_S)) dialog.file_save(util::Dialog::DOCUMENT_EXPORT); else if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiKey_S)) save(selectedDocument); if (shortcut_is_pressed(ImGuiMod_Ctrl | ImGuiKey_W)) close(selectedDocument); } void Window::dialog_selection_update() { if (dialog.is_selected(util::Dialog::SCHEMA_FOLDER_OPEN)) { folder_open(dialog.path); dialog.reset(); } else if (dialog.is_selected(util::Dialog::DOCUMENT_EXPORT)) { export_to(selectedDocument, dialog.path); dialog.reset(); } } void Window::editor_menu_update() { if (!ImGui::BeginMenuBar()) return; if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("Open Folder", "Ctrl+O")) dialog.folder_open(util::Dialog::SCHEMA_FOLDER_OPEN); if (ImGui::BeginMenu("Open Recent", !recentFiles.empty())) { for (int recentIndex = 0; recentIndex < (int)recentFiles.size(); ++recentIndex) { ImGui::PushID(recentIndex); auto label = recentFiles[recentIndex].string(); if (ImGui::MenuItem(label.c_str())) folder_open(recentFiles[recentIndex]); ImGui::PopID(); } ImGui::EndMenu(); } if (ImGui::MenuItem("Save", "Ctrl+S", false, selectedDocument >= 0 && selectedDocument < (int)documents.size())) save(selectedDocument); if (ImGui::MenuItem("Export", "Ctrl+Shift+S", false, selectedDocument >= 0 && selectedDocument < (int)documents.size())) dialog.file_save(util::Dialog::DOCUMENT_EXPORT); if (ImGui::MenuItem("Close", "Ctrl+W", false, selectedDocument >= 0 && selectedDocument < (int)documents.size())) close(selectedDocument); ImGui::EndMenu(); } if (ImGui::BeginMenu("Edit")) { auto isCanUndo = selectedDocument >= 0 && selectedDocument < (int)documents.size() && documents[selectedDocument].can_undo(); auto isCanRedo = selectedDocument >= 0 && selectedDocument < (int)documents.size() && documents[selectedDocument].can_redo(); if (ImGui::MenuItem("Undo", "Ctrl+Z", false, isCanUndo)) undo(selectedDocument); if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, isCanRedo)) redo(selectedDocument); ImGui::EndMenu(); } ImGui::EndMenuBar(); } void Window::editor_documents_update(Resources& resources) { if (documents.empty() || !ImGui::BeginTabBar("schemas", ImGuiTabBarFlags_Reorderable)) return; auto closeRequest = -1; for (int i = 0; i < (int)documents.size(); ++i) { ImGui::PushID(i); auto label = document_path_label_get(documents[i].path) + (documents[i].isDirty ? " *" : ""); label += std::format("###{}", documents[i].path.string()); auto isOpen = true; auto isTabOpen = ImGui::BeginTabItem(label.c_str(), &isOpen); if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayNormal)) ImGui::SetTooltip("%s", documents[i].path.string().c_str()); if (isTabOpen) { selectedDocument = i; if (ImGui::BeginTabBar("##Document Tabs")) { if (ImGui::BeginTabItem("Dialogue")) { document_dialogue_update(resources, documents[i]); ImGui::EndTabItem(); } ImGui::EndTabBar(); } ImGui::EndTabItem(); } if (!isOpen) closeRequest = i; ImGui::PopID(); } ImGui::EndTabBar(); if (closeRequest >= 0) close(closeRequest); } void Window::document_dialogue_update(Resources& resources, Document& document) { auto* dialogue = document.dialogue_schema_get(); if (!dialogue || !dialogue->isExists) { ImGui::TextUnformatted("No dialogue.xml found."); return; } if (!dialogue->isValid) { ImGui::TextUnformatted("dialogue.xml is not valid."); return; } if (ImGui::IsAnyItemActive()) document.edit_snapshot_begin(); std::vector loadedSchemas{}; for (auto& schemaFile : document.schemas) { if (!schemaFile.isValid) continue; loadedSchemas.push_back({.path = &schemaFile.path, .schema = &schemaFile.schema}); } auto before = dialogue->schema; if (document.editor.update(resources, dialogue->schema, dialogue->path, dialog, loadedSchemas)) { before = document.edit_snapshot_take_or(std::move(before)); if (before.element_serialize(0) != dialogue->schema.element_serialize(0)) { auto message = Document::change_message_get(before, dialogue->schema); document.snapshot_push(std::move(before), message); } else document.isDirty = true; } else if (!ImGui::IsAnyItemActive()) document.edit_snapshot_clear(); } void Window::editor_update(Resources& resources) { if (ImGui::BeginChild("##Editor", ImVec2(), ImGuiChildFlags_None, EDITOR_CHILD_FLAGS)) { editor_menu_update(); if (ImGui::BeginPopupContextWindow("developer-context", ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) { auto isCanUndo = selectedDocument >= 0 && selectedDocument < (int)documents.size() && documents[selectedDocument].can_undo(); auto isCanRedo = selectedDocument >= 0 && selectedDocument < (int)documents.size() && documents[selectedDocument].can_redo(); if (ImGui::MenuItem("Undo", "Ctrl+Z", false, isCanUndo)) isContextUndoRequested = true; if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, isCanRedo)) isContextRedoRequested = true; ImGui::EndPopup(); } editor_documents_update(resources); } ImGui::EndChild(); } void Window::update(Resources& resources, SDL_Window* window, state::Play* play) { dialog.window_set(window); auto* settingsRoot = resources.settings.root(); if (!settingsRoot || !settingsRoot->isDeveloperConsoleEnabled) { resources.isDeveloper = false; isDeveloperOpen = false; action = {}; console.action = {}; return; } if (!resources.isDeveloper && shortcut_is_pressed(ImGuiKey_GraveAccent)) { resources.isDeveloper = true; if (selectedTab == Tab::CONSOLE) console.focus_request(); } if (!resources.isDeveloper) { isDeveloperOpen = false; return; } recent_files_load(); auto* viewport = ImGui::GetMainViewport(); auto windowSize = ImVec2(viewport->WorkSize.x * DEVELOPER_WINDOW_SIZE_RATIO, viewport->WorkSize.y * DEVELOPER_WINDOW_SIZE_RATIO); auto windowPosition = ImVec2(viewport->WorkPos.x + (viewport->WorkSize.x * CENTER_RATIO), viewport->WorkPos.y + (viewport->WorkSize.y * CENTER_RATIO)); ImGui::SetNextWindowPos(windowPosition, ImGuiCond_Appearing, ImVec2(CENTER_RATIO, CENTER_RATIO)); ImGui::SetNextWindowSize(windowSize, ImGuiCond_Appearing); if (!isDeveloperOpen) { ImGui::SetNextWindowFocus(); if (selectedTab == Tab::CONSOLE) console.focus_request(); } isDeveloperOpen = true; isContextUndoRequested = false; isContextRedoRequested = false; action = {}; console.action = {}; gIsDocumentUndoMenuRequested = false; gIsDocumentRedoMenuRequested = false; if (selectedTab == Tab::EDITOR) editor_shortcuts_update(); dialog_selection_update(); auto gameStyle = ImGui::GetStyle(); ImGui::GetStyle() = ImGuiStyle{}; ImGui::StyleColorsDark(); ImGui::PushFont(resources.font.get(), resource::Font::SMALL); if (ImGui::Begin("Developer", &resources.isDeveloper, DEVELOPER_WINDOW_FLAGS)) { auto isFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); if (isFocused && (shortcut_is_pressed(ImGuiKey_GraveAccent) || shortcut_is_pressed(ImGuiKey_Escape))) resources.isDeveloper = false; if (ImGui::BeginTabBar("##Developer Tabs")) { auto isWasConsole = selectedTab == Tab::CONSOLE; if (ImGui::BeginTabItem("Console")) { selectedTab = Tab::CONSOLE; if (!isWasConsole) console.focus_request(); console.update(resources, play); ImGui::EndTabItem(); } if (ImGui::BeginTabItem("Editor")) { selectedTab = Tab::EDITOR; editor_update(resources); ImGui::EndTabItem(); } ImGui::EndTabBar(); } } ImGui::End(); if (gIsDocumentUndoMenuRequested) isContextUndoRequested = true; if (gIsDocumentRedoMenuRequested) isContextRedoRequested = true; action = console.action; if (isContextUndoRequested) undo(selectedDocument); if (isContextRedoRequested) redo(selectedDocument); toasts.update(); ImGui::PopFont(); ImGui::GetStyle() = gameStyle; } }