From 300cf070b24bda5a4fe2a7c93e9afb6dce904ac0 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 19:49:03 -0700 Subject: [PATCH] Step 160: add theme engine and JSON themes --- PROGRESS.md | 1 + editor/CMakeLists.txt | 4 + editor/src/CodeEditorWidget.h | 106 +++++++++---- editor/src/SettingsManager.h | 2 +- editor/src/ThemeEngine.h | 233 +++++++++++++++++++++++++++++ editor/src/main.cpp | 44 ++++-- editor/tests/step160_test.cpp | 45 ++++++ editor/themes/dracula.json | 67 +++++++++ editor/themes/monokai.json | 67 +++++++++ editor/themes/nord.json | 67 +++++++++ editor/themes/solarized_dark.json | 67 +++++++++ editor/themes/solarized_light.json | 67 +++++++++ editor/themes/vscode_dark.json | 67 +++++++++ editor/themes/vscode_light.json | 67 +++++++++ sprint5_plan.md | 2 +- 15 files changed, 863 insertions(+), 43 deletions(-) create mode 100644 editor/src/ThemeEngine.h create mode 100644 editor/tests/step160_test.cpp create mode 100644 editor/themes/dracula.json create mode 100644 editor/themes/monokai.json create mode 100644 editor/themes/nord.json create mode 100644 editor/themes/solarized_dark.json create mode 100644 editor/themes/solarized_light.json create mode 100644 editor/themes/vscode_dark.json create mode 100644 editor/themes/vscode_light.json diff --git a/PROGRESS.md b/PROGRESS.md index da7ad14..00045c7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -507,3 +507,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 157: Multi-agent roles with permission policy, agent provenance in transform history, and UI role controls. 8/8 tests pass. | | 2026-02-10 | Codex | Step 158: Agent workflow recorder with JSON export, replay support, and RPC wiring. 5/5 tests pass. | | 2026-02-10 | Codex | Step 159: Agent marketplace registry with UI panel, install toggles, and registry serialization. 6/6 tests pass. | +| 2026-02-10 | Codex | Step 160: Theme engine with JSON themes, ImGui styling, and syntax/editor color mapping. 4/4 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 036b442..ccb6bca 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -894,6 +894,10 @@ add_executable(step159_test tests/step159_test.cpp) target_include_directories(step159_test PRIVATE src) target_link_libraries(step159_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step160_test tests/step160_test.cpp) +target_include_directories(step160_test PRIVATE src) +target_link_libraries(step160_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index 7553aa3..03d4402 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -8,6 +8,7 @@ #include "imgui.h" #include "SyntaxHighlighter.h" +#include "ThemeEngine.h" #include "EditorMode.h" #include #include @@ -306,7 +307,10 @@ public: // Gutter background ImVec2 gutterA(origin.x, y); ImVec2 gutterB(origin.x + gutterWidth, y + lineHeight); - drawList->AddRectFilled(gutterA, gutterB, IM_COL32(20, 20, 20, 255)); + drawList->AddRectFilled( + gutterA, gutterB, + ThemeEngine::instance().editorColor("gutter_bg", + IM_COL32(20, 20, 20, 255))); // Line number (right-aligned) if (options.showLineNumbers) { @@ -314,14 +318,19 @@ public: snprintf(numBuf, sizeof(numBuf), "%d", ln + 1); ImVec2 numSize = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, numBuf); ImVec2 numPos(origin.x + gutterWidth - 4.0f - numSize.x, y); - drawList->AddText(font, font->FontSize, numPos, IM_COL32(120, 120, 120, 255), numBuf); + drawList->AddText(font, font->FontSize, numPos, + ThemeEngine::instance().editorColor("gutter_text", + IM_COL32(120, 120, 120, 255)), + numBuf); } // Diagnostics marker bool hasError = lineIn(options.errorLines, ln); bool hasWarn = lineIn(options.warningLines, ln); if (hasError || hasWarn) { - ImU32 color = hasError ? IM_COL32(220, 80, 80, 255) : IM_COL32(220, 160, 60, 255); + ImU32 color = hasError + ? ThemeEngine::instance().editorColor("diag_error", IM_COL32(220, 80, 80, 255)) + : ThemeEngine::instance().editorColor("diag_warning", IM_COL32(220, 160, 60, 255)); ImVec2 center(origin.x + 3.0f, y + lineHeight * 0.5f); drawList->AddCircleFilled(center, 3.0f, color); } @@ -361,7 +370,8 @@ public: SuggestionMarker suggestion; if (suggestionAtLine(*options.suggestions, ln, suggestion)) { ImVec2 center(origin.x + 20.0f, y + lineHeight * 0.5f); - ImU32 color = IM_COL32(240, 200, 40, 255); + ImU32 color = ThemeEngine::instance().editorColor("suggestion", + IM_COL32(240, 200, 40, 255)); drawList->AddCircleFilled(center, 3.0f, color); ImVec2 a(center.x - 4.0f, center.y - 4.0f); ImVec2 b(center.x + 4.0f, center.y + 4.0f); @@ -384,14 +394,19 @@ public: AnnotationConflictMarker conflict; if (conflictAtLine(*options.conflicts, ln, conflict)) { ImVec2 center(origin.x + 12.0f, y + lineHeight * 0.5f); - drawList->AddCircle(center, 5.0f, IM_COL32(220, 80, 80, 255), 12, 1.5f); + drawList->AddCircle(center, 5.0f, + ThemeEngine::instance().editorColor("conflict", + IM_COL32(220, 80, 80, 255)), + 12, 1.5f); if (ln == std::min(conflict.childLine, conflict.parentLine) && conflict.childLine >= 0 && conflict.parentLine >= 0) { float y1 = textBase.y + conflict.childLine * lineHeight + lineHeight * 0.5f; float y2 = textBase.y + conflict.parentLine * lineHeight + lineHeight * 0.5f; drawList->AddLine(ImVec2(origin.x + 12.0f, y1), ImVec2(origin.x + 12.0f, y2), - IM_COL32(220, 80, 80, 180), 1.0f); + ThemeEngine::instance().editorColor("conflict_line", + IM_COL32(220, 80, 80, 180)), + 1.0f); } ImVec2 a(center.x - 5.0f, center.y - 5.0f); ImVec2 b(center.x + 5.0f, center.y + 5.0f); @@ -408,9 +423,15 @@ public: if (fold) { ImVec2 triCenter(origin.x + 6.0f, y + lineHeight * 0.5f); if (fold->folded) { - drawTriangle(drawList, triCenter, IM_COL32(160, 160, 160, 255), true); + drawTriangle(drawList, triCenter, + ThemeEngine::instance().editorColor("fold_indicator", + IM_COL32(160, 160, 160, 255)), + true); } else { - drawTriangle(drawList, triCenter, IM_COL32(160, 160, 160, 255), false); + drawTriangle(drawList, triCenter, + ThemeEngine::instance().editorColor("fold_indicator", + IM_COL32(160, 160, 160, 255)), + false); } } @@ -418,7 +439,9 @@ public: if (options.showCurrentLine && ln == currentLine) { ImVec2 hlA(textBase.x, y); ImVec2 hlB(textBase.x + textAreaWidth, y + lineHeight); - drawList->AddRectFilled(hlA, hlB, IM_COL32(40, 40, 40, 120)); + drawList->AddRectFilled(hlA, hlB, + ThemeEngine::instance().editorColor("line_highlight", + IM_COL32(40, 40, 40, 120))); } if (options.highlightLine >= 0 && ln == options.highlightLine) { ImVec2 hlA(textBase.x, y); @@ -445,7 +468,9 @@ public: int colB = lineSelEnd - start; ImVec2 a(textBase.x + colA * charAdvance, y); ImVec2 b(textBase.x + colB * charAdvance, y + lineHeight); - drawList->AddRectFilled(a, b, IM_COL32(60, 100, 160, 120)); + drawList->AddRectFilled(a, b, + ThemeEngine::instance().editorColor("selection", + IM_COL32(60, 100, 160, 120))); } } @@ -494,7 +519,10 @@ public: ImVec2 textSize = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, marker.message.c_str()); ImVec2 bgA(tagPos.x - 2.0f, tagPos.y); ImVec2 bgB(tagPos.x + textSize.x + 6.0f, tagPos.y + lineHeight * 0.8f); - drawList->AddRectFilled(bgA, bgB, IM_COL32(30, 30, 30, 220), 2.0f); + drawList->AddRectFilled(bgA, bgB, + ThemeEngine::instance().editorColor("annotation_tag_bg", + IM_COL32(30, 30, 30, 220)), + 2.0f); drawList->AddText(font, font->FontSize, ImVec2(tagPos.x + 2.0f, tagPos.y), marker.color, marker.message.c_str()); } @@ -503,7 +531,10 @@ public: // Folded placeholder if (fold && fold->folded) { ImVec2 p(textBase.x + len * charAdvance + 6.0f, y); - drawList->AddText(font, font->FontSize, p, IM_COL32(140, 140, 140, 255), "{...}"); + drawList->AddText(font, font->FontSize, p, + ThemeEngine::instance().editorColor("fold_placeholder", + IM_COL32(140, 140, 140, 255)), + "{...}"); } if (options.diagnostics) { @@ -539,7 +570,10 @@ public: int col = cursor_ - lineStart; float x = textBase.x + col * charAdvance; float y = textBase.y + curLine * lineHeight; - drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), IM_COL32(240, 240, 240, 255), 1.0f); + drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), + ThemeEngine::instance().editorColor("caret", + IM_COL32(240, 240, 240, 255)), + 1.0f); } } @@ -548,11 +582,17 @@ public: float miniHeight = lineCount * lineHeight; ImVec2 miniA(minimapBaseX, minimapBaseY); ImVec2 miniB(minimapBaseX + minimapWidth, minimapBaseY + miniHeight); - drawList->AddRectFilled(miniA, miniB, IM_COL32(18, 18, 18, 255)); + drawList->AddRectFilled(miniA, miniB, + ThemeEngine::instance().editorColor("minimap_bg", + IM_COL32(18, 18, 18, 255))); for (int ln = 0; ln < lineCount; ++ln) { float y = minimapBaseY + ln * lineHeight; - ImU32 color = IM_COL32(80, 80, 80, 255); - if (ln == currentLine) color = IM_COL32(120, 120, 160, 255); + ImU32 color = ThemeEngine::instance().editorColor("minimap_line", + IM_COL32(80, 80, 80, 255)); + if (ln == currentLine) { + color = ThemeEngine::instance().editorColor("minimap_line_active", + IM_COL32(120, 120, 160, 255)); + } drawList->AddRectFilled(ImVec2(minimapBaseX + 2.0f, y), ImVec2(minimapBaseX + minimapWidth - 2.0f, y + 2.0f), color); @@ -563,7 +603,9 @@ public: viewEnd = std::max(0.0f, std::min(1.0f, viewEnd)); ImVec2 vA(minimapBaseX, minimapBaseY + viewStart * miniHeight); ImVec2 vB(minimapBaseX + minimapWidth, minimapBaseY + viewEnd * miniHeight); - drawList->AddRect(vA, vB, IM_COL32(120, 160, 220, 180)); + drawList->AddRect(vA, vB, + ThemeEngine::instance().editorColor("minimap_view", + IM_COL32(120, 160, 220, 180))); } ImGui::EndChild(); @@ -628,20 +670,22 @@ private: } static ImU32 colorFor(TokenCategory cat) { + ImU32 fallback = IM_COL32(220, 220, 220, 255); switch (cat) { - case TokenCategory::Keyword: return IM_COL32(196, 126, 220, 255); - case TokenCategory::String: return IM_COL32(207, 138, 94, 255); - case TokenCategory::Comment: return IM_COL32(107, 133, 89, 255); - case TokenCategory::Number: return IM_COL32(181, 204, 140, 255); - case TokenCategory::Function: return IM_COL32(220, 220, 140, 255); - case TokenCategory::Parameter: return IM_COL32(153, 199, 229, 255); - case TokenCategory::Type: return IM_COL32(77, 179, 174, 255); - case TokenCategory::Operator: return IM_COL32(220, 220, 220, 255); - case TokenCategory::Punctuation: return IM_COL32(160, 160, 160, 255); - case TokenCategory::Builtin: return IM_COL32(77, 179, 229, 255); - case TokenCategory::Identifier: return IM_COL32(160, 200, 230, 255); - default: return IM_COL32(220, 220, 220, 255); + case TokenCategory::Keyword: fallback = IM_COL32(196, 126, 220, 255); break; + case TokenCategory::String: fallback = IM_COL32(207, 138, 94, 255); break; + case TokenCategory::Comment: fallback = IM_COL32(107, 133, 89, 255); break; + case TokenCategory::Number: fallback = IM_COL32(181, 204, 140, 255); break; + case TokenCategory::Function: fallback = IM_COL32(220, 220, 140, 255); break; + case TokenCategory::Parameter: fallback = IM_COL32(153, 199, 229, 255); break; + case TokenCategory::Type: fallback = IM_COL32(77, 179, 174, 255); break; + case TokenCategory::Operator: fallback = IM_COL32(220, 220, 220, 255); break; + case TokenCategory::Punctuation: fallback = IM_COL32(160, 160, 160, 255); break; + case TokenCategory::Builtin: fallback = IM_COL32(77, 179, 229, 255); break; + case TokenCategory::Identifier: fallback = IM_COL32(160, 200, 230, 255); break; + default: break; } + return ThemeEngine::instance().syntaxColor(cat, fallback); } static int lineFromPos(int pos, const std::vector& lineStarts) { @@ -778,7 +822,9 @@ private: endCol = std::max(startCol, endCol); float xStart = textBaseX + startCol * charAdvance; float xEnd = textBaseX + endCol * charAdvance; - ImU32 color = (d.severity == 1) ? IM_COL32(220, 80, 80, 255) : IM_COL32(220, 160, 60, 255); + ImU32 color = (d.severity == 1) + ? ThemeEngine::instance().editorColor("diag_error", IM_COL32(220, 80, 80, 255)) + : ThemeEngine::instance().editorColor("diag_warning", IM_COL32(220, 160, 60, 255)); float x = xStart; float amp = 2.0f; bool up = true; diff --git a/editor/src/SettingsManager.h b/editor/src/SettingsManager.h index 28b8f11..5943a39 100644 --- a/editor/src/SettingsManager.h +++ b/editor/src/SettingsManager.h @@ -150,7 +150,7 @@ private: std::string emacsConfigPath_; int fontSize_ = 15; int tabSize_ = 4; - std::string theme_ = "Dark"; + std::string theme_ = "VSCode Dark"; int autoSaveSeconds_ = 0; bool showMinimap_ = false; bool showLineNumbers_ = true; diff --git a/editor/src/ThemeEngine.h b/editor/src/ThemeEngine.h new file mode 100644 index 0000000..c3daa01 --- /dev/null +++ b/editor/src/ThemeEngine.h @@ -0,0 +1,233 @@ +#pragma once +// Step 160: Theme engine + +#include +#include +#include +#include +#include +#include +#include +#include +#include "imgui.h" +#include "SyntaxHighlighter.h" + +using json = nlohmann::json; + +struct ThemeDefinition { + std::string name; + std::unordered_map syntaxColors; + std::unordered_map editorColors; + std::unordered_map imguiColors; +}; + +class ThemeEngine { +public: + static ThemeEngine& instance() { + static ThemeEngine inst; + return inst; + } + + void clear() { + themes_.clear(); + current_ = -1; + } + + bool loadThemesFromDirectory(const std::string& dir) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(dir, ec)) return false; + bool loaded = false; + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + auto path = entry.path(); + if (path.extension() != ".json") continue; + if (loadThemeFile(path.string())) loaded = true; + } + return loaded; + } + + bool loadThemeFile(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) return false; + std::ostringstream ss; + ss << in.rdbuf(); + return loadThemeFromJson(ss.str()); + } + + bool loadThemeFromJson(const std::string& text) { + try { + json j = json::parse(text); + ThemeDefinition theme; + if (!parseTheme(j, theme)) return false; + registerTheme(theme); + return true; + } catch (...) { + return false; + } + } + + void registerTheme(const ThemeDefinition& theme) { + for (auto& t : themes_) { + if (t.name == theme.name) { + t = theme; + return; + } + } + themes_.push_back(theme); + } + + std::vector listThemes() const { + std::vector names; + for (const auto& t : themes_) names.push_back(t.name); + std::sort(names.begin(), names.end()); + return names; + } + + bool applyTheme(const std::string& name) { + for (size_t i = 0; i < themes_.size(); ++i) { + if (themes_[i].name == name) { + current_ = (int)i; + applyToImGui(themes_[i]); + return true; + } + } + return false; + } + + std::string currentThemeName() const { + if (current_ < 0 || current_ >= (int)themes_.size()) return ""; + return themes_[current_].name; + } + + ImU32 syntaxColor(TokenCategory cat, ImU32 fallback) const { + const ThemeDefinition* theme = currentTheme(); + if (!theme) return fallback; + std::string key = SyntaxHighlighter::categoryName(cat); + auto it = theme->syntaxColors.find(key); + return it != theme->syntaxColors.end() ? it->second : fallback; + } + + ImU32 editorColor(const std::string& key, ImU32 fallback) const { + const ThemeDefinition* theme = currentTheme(); + if (!theme) return fallback; + auto it = theme->editorColors.find(key); + return it != theme->editorColors.end() ? it->second : fallback; + } + +private: + ThemeEngine() = default; + + const ThemeDefinition* currentTheme() const { + if (current_ < 0 || current_ >= (int)themes_.size()) return nullptr; + return &themes_[current_]; + } + + static bool parseHexColor(const std::string& text, + ImU32& outU32, + ImVec4& outVec) { + std::string hex = text; + if (!hex.empty() && hex[0] == '#') hex = hex.substr(1); + if (hex.size() != 6 && hex.size() != 8) return false; + auto toByte = [](const std::string& s) { + return (int)std::strtol(s.c_str(), nullptr, 16); + }; + int r = toByte(hex.substr(0, 2)); + int g = toByte(hex.substr(2, 2)); + int b = toByte(hex.substr(4, 2)); + int a = 255; + if (hex.size() == 8) a = toByte(hex.substr(6, 2)); + outU32 = IM_COL32(r, g, b, a); + outVec = ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); + return true; + } + + static int imguiColorId(const std::string& name) { + if (name == "Text") return ImGuiCol_Text; + if (name == "WindowBg") return ImGuiCol_WindowBg; + if (name == "ChildBg") return ImGuiCol_ChildBg; + if (name == "PopupBg") return ImGuiCol_PopupBg; + if (name == "Border") return ImGuiCol_Border; + if (name == "FrameBg") return ImGuiCol_FrameBg; + if (name == "FrameBgHovered") return ImGuiCol_FrameBgHovered; + if (name == "FrameBgActive") return ImGuiCol_FrameBgActive; + if (name == "TitleBg") return ImGuiCol_TitleBg; + if (name == "TitleBgActive") return ImGuiCol_TitleBgActive; + if (name == "Header") return ImGuiCol_Header; + if (name == "HeaderHovered") return ImGuiCol_HeaderHovered; + if (name == "HeaderActive") return ImGuiCol_HeaderActive; + if (name == "Tab") return ImGuiCol_Tab; + if (name == "TabHovered") return ImGuiCol_TabHovered; + if (name == "TabActive") return ImGuiCol_TabActive; + if (name == "TabUnfocused") return ImGuiCol_TabUnfocused; + if (name == "TabUnfocusedActive") return ImGuiCol_TabUnfocusedActive; + if (name == "Button") return ImGuiCol_Button; + if (name == "ButtonHovered") return ImGuiCol_ButtonHovered; + if (name == "ButtonActive") return ImGuiCol_ButtonActive; + if (name == "ScrollbarBg") return ImGuiCol_ScrollbarBg; + if (name == "ScrollbarGrab") return ImGuiCol_ScrollbarGrab; + if (name == "ScrollbarGrabHovered") return ImGuiCol_ScrollbarGrabHovered; + if (name == "ScrollbarGrabActive") return ImGuiCol_ScrollbarGrabActive; + if (name == "CheckMark") return ImGuiCol_CheckMark; + if (name == "SliderGrab") return ImGuiCol_SliderGrab; + if (name == "SliderGrabActive") return ImGuiCol_SliderGrabActive; + if (name == "Separator") return ImGuiCol_Separator; + if (name == "SeparatorHovered") return ImGuiCol_SeparatorHovered; + if (name == "SeparatorActive") return ImGuiCol_SeparatorActive; + return -1; + } + + static bool parseTheme(const json& j, ThemeDefinition& out) { + if (!j.is_object()) return false; + out.name = j.value("name", ""); + if (out.name.empty()) return false; + + if (j.contains("syntax") && j["syntax"].is_object()) { + for (const auto& [key, value] : j["syntax"].items()) { + if (!value.is_string()) continue; + ImU32 u32; + ImVec4 v4; + if (parseHexColor(value.get(), u32, v4)) { + out.syntaxColors[key] = u32; + } + } + } + + if (j.contains("editor") && j["editor"].is_object()) { + for (const auto& [key, value] : j["editor"].items()) { + if (!value.is_string()) continue; + ImU32 u32; + ImVec4 v4; + if (parseHexColor(value.get(), u32, v4)) { + out.editorColors[key] = u32; + } + } + } + + if (j.contains("imgui") && j["imgui"].is_object()) { + for (const auto& [key, value] : j["imgui"].items()) { + if (!value.is_string()) continue; + ImU32 u32; + ImVec4 v4; + if (!parseHexColor(value.get(), u32, v4)) continue; + int colId = imguiColorId(key); + if (colId >= 0) { + out.imguiColors[colId] = v4; + } + } + } + return true; + } + + static void applyToImGui(const ThemeDefinition& theme) { + ImGuiStyle& style = ImGui::GetStyle(); + for (const auto& [colId, color] : theme.imguiColors) { + if (colId >= 0 && colId < ImGuiCol_COUNT) { + style.Colors[colId] = color; + } + } + } + + std::vector themes_; + int current_ = -1; +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index ebf19cf..07d0d40 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -16,6 +16,7 @@ #include "EditorState.h" #include "EditorUtils.h" #include "CompletionUtils.h" +#include "ThemeEngine.h" static std::string emacsChordForEvent(SDL_Keycode sym, SDL_Keymod mods) { std::string base; @@ -105,17 +106,19 @@ int main(int, char**) { uiFont = io.Fonts->AddFontDefault(); } - SetupVSCodeDarkTheme(); - ImGui_ImplSDL2_InitForOpenGL(window, gl_context); ImGui_ImplOpenGL3_Init(glsl_version); // Editor state EditorState state; state.init(); - if (state.settings.getTheme() == "Light") { - SetupVSCodeLightTheme(); - } else { + ThemeEngine& themes = ThemeEngine::instance(); + themes.loadThemesFromDirectory("themes"); + themes.loadThemesFromDirectory("editor/themes"); + std::string themeName = state.settings.getTheme(); + if (themeName == "Dark") themeName = "VSCode Dark"; + if (themeName == "Light") themeName = "VSCode Light"; + if (!themes.applyTheme(themeName)) { SetupVSCodeDarkTheme(); } io.FontGlobalScale = state.settings.getFontSize() / baseFontSize; @@ -835,12 +838,31 @@ int main(int, char**) { settingsChanged = true; } - const char* themeLabels[] = {"Dark", "Light"}; - int themeIndex = state.settings.getTheme() == "Light" ? 1 : 0; - if (ImGui::Combo("Theme", &themeIndex, themeLabels, 2)) { - state.settings.setTheme(themeLabels[themeIndex]); - if (themeIndex == 1) SetupVSCodeLightTheme(); - else SetupVSCodeDarkTheme(); + std::vector themeNames = ThemeEngine::instance().listThemes(); + if (themeNames.empty()) { + themeNames = {"VSCode Dark", "VSCode Light"}; + } + std::string currentTheme = state.settings.getTheme(); + if (currentTheme == "Dark") currentTheme = "VSCode Dark"; + if (currentTheme == "Light") currentTheme = "VSCode Light"; + int themeIndex = 0; + for (size_t i = 0; i < themeNames.size(); ++i) { + if (themeNames[i] == currentTheme) { + themeIndex = (int)i; + break; + } + } + std::vector themeLabels; + themeLabels.reserve(themeNames.size()); + for (const auto& name : themeNames) themeLabels.push_back(name.c_str()); + if (ImGui::Combo("Theme", &themeIndex, themeLabels.data(), (int)themeLabels.size())) { + state.settings.setTheme(themeNames[themeIndex]); + if (!ThemeEngine::instance().applyTheme(themeNames[themeIndex])) { + if (themeNames[themeIndex].find("Light") != std::string::npos) + SetupVSCodeLightTheme(); + else + SetupVSCodeDarkTheme(); + } settingsChanged = true; } diff --git a/editor/tests/step160_test.cpp b/editor/tests/step160_test.cpp new file mode 100644 index 0000000..42350fa --- /dev/null +++ b/editor/tests/step160_test.cpp @@ -0,0 +1,45 @@ +// Step 160 TDD Test: Theme engine +#include "ThemeEngine.h" +#include "imgui.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + ImGui::CreateContext(); + + ThemeEngine& themes = ThemeEngine::instance(); + themes.clear(); + + std::string jsonText = R"({ + "name": "TestTheme", + "syntax": { "keyword": "#FF0000" }, + "editor": { "gutter_bg": "#001122" }, + "imgui": { "WindowBg": "#112233" } + })"; + + expect(themes.loadThemeFromJson(jsonText), "load theme from json", passed, failed); + expect(themes.applyTheme("TestTheme"), "apply theme", passed, failed); + + ImU32 keyword = themes.syntaxColor(TokenCategory::Keyword, IM_COL32(0,0,0,255)); + ImU32 gutter = themes.editorColor("gutter_bg", IM_COL32(0,0,0,255)); + expect(keyword == IM_COL32(255, 0, 0, 255), "syntax color parsed", passed, failed); + expect(gutter == IM_COL32(0, 17, 34, 255), "editor color parsed", passed, failed); + + ImGui::DestroyContext(); + + std::cout << "\n=== Step 160 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/editor/themes/dracula.json b/editor/themes/dracula.json new file mode 100644 index 0000000..8f7d07c --- /dev/null +++ b/editor/themes/dracula.json @@ -0,0 +1,67 @@ +{ + "name": "Dracula", + "syntax": { + "keyword": "#FF79C6", + "string": "#F1FA8C", + "comment": "#6272A4", + "number": "#BD93F9", + "function": "#50FA7B", + "parameter": "#FFB86C", + "type": "#8BE9FD", + "operator": "#F8F8F2", + "punctuation": "#F8F8F2", + "builtin": "#8BE9FD", + "identifier": "#F8F8F2", + "plain": "#F8F8F2" + }, + "editor": { + "gutter_bg": "#282A36", + "gutter_text": "#6272A4", + "diag_error": "#FF5555", + "diag_warning": "#FFB86C", + "suggestion": "#F1FA8C", + "conflict": "#FF5555", + "conflict_line": "#FF5555B4", + "fold_indicator": "#6272A4", + "line_highlight": "#44475A", + "selection": "#44475A", + "annotation_tag_bg": "#44475A", + "fold_placeholder": "#6272A4", + "caret": "#F8F8F2", + "minimap_bg": "#2B2C36", + "minimap_line": "#3A3B45", + "minimap_line_active": "#525363", + "minimap_view": "#525363" + }, + "imgui": { + "WindowBg": "#282A36", + "ChildBg": "#282A36", + "PopupBg": "#44475A", + "Border": "#44475A", + "Text": "#F8F8F2", + "FrameBg": "#44475A", + "FrameBgHovered": "#525363", + "FrameBgActive": "#525363", + "TitleBg": "#282A36", + "TitleBgActive": "#44475A", + "Header": "#44475A", + "HeaderHovered": "#525363", + "HeaderActive": "#525363", + "Tab": "#44475A", + "TabHovered": "#525363", + "TabActive": "#282A36", + "Button": "#44475A", + "ButtonHovered": "#525363", + "ButtonActive": "#525363", + "ScrollbarBg": "#282A36", + "ScrollbarGrab": "#44475A", + "ScrollbarGrabHovered": "#525363", + "ScrollbarGrabActive": "#525363", + "CheckMark": "#F8F8F2", + "SliderGrab": "#6272A4", + "SliderGrabActive": "#7080B0", + "Separator": "#44475A", + "SeparatorHovered": "#525363", + "SeparatorActive": "#525363" + } +} diff --git a/editor/themes/monokai.json b/editor/themes/monokai.json new file mode 100644 index 0000000..2a4c838 --- /dev/null +++ b/editor/themes/monokai.json @@ -0,0 +1,67 @@ +{ + "name": "Monokai", + "syntax": { + "keyword": "#F92672", + "string": "#E6DB74", + "comment": "#75715E", + "number": "#AE81FF", + "function": "#A6E22E", + "parameter": "#FD971F", + "type": "#66D9EF", + "operator": "#F8F8F2", + "punctuation": "#F8F8F2", + "builtin": "#66D9EF", + "identifier": "#F8F8F2", + "plain": "#F8F8F2" + }, + "editor": { + "gutter_bg": "#272822", + "gutter_text": "#75715E", + "diag_error": "#F92672", + "diag_warning": "#FD971F", + "suggestion": "#E6DB74", + "conflict": "#F92672", + "conflict_line": "#F92672B4", + "fold_indicator": "#A6A28C", + "line_highlight": "#3E3D32", + "selection": "#49483E", + "annotation_tag_bg": "#3B3A32", + "fold_placeholder": "#A6A28C", + "caret": "#F8F8F0", + "minimap_bg": "#2D2A2E", + "minimap_line": "#3F3F3F", + "minimap_line_active": "#5B5B5B", + "minimap_view": "#5E5E5E" + }, + "imgui": { + "WindowBg": "#272822", + "ChildBg": "#272822", + "PopupBg": "#2D2A2E", + "Border": "#3B3A32", + "Text": "#F8F8F2", + "FrameBg": "#3B3A32", + "FrameBgHovered": "#45433A", + "FrameBgActive": "#45433A", + "TitleBg": "#272822", + "TitleBgActive": "#2D2A2E", + "Header": "#3B3A32", + "HeaderHovered": "#45433A", + "HeaderActive": "#45433A", + "Tab": "#3B3A32", + "TabHovered": "#45433A", + "TabActive": "#272822", + "Button": "#45433A", + "ButtonHovered": "#4E4B40", + "ButtonActive": "#4E4B40", + "ScrollbarBg": "#2D2A2E", + "ScrollbarGrab": "#45433A", + "ScrollbarGrabHovered": "#4E4B40", + "ScrollbarGrabActive": "#4E4B40", + "CheckMark": "#F8F8F2", + "SliderGrab": "#5B5B5B", + "SliderGrabActive": "#6A6A6A", + "Separator": "#3B3A32", + "SeparatorHovered": "#5B5B5B", + "SeparatorActive": "#6A6A6A" + } +} diff --git a/editor/themes/nord.json b/editor/themes/nord.json new file mode 100644 index 0000000..43db1b6 --- /dev/null +++ b/editor/themes/nord.json @@ -0,0 +1,67 @@ +{ + "name": "Nord", + "syntax": { + "keyword": "#81A1C1", + "string": "#A3BE8C", + "comment": "#616E88", + "number": "#B48EAD", + "function": "#88C0D0", + "parameter": "#D08770", + "type": "#8FBCBB", + "operator": "#D8DEE9", + "punctuation": "#D8DEE9", + "builtin": "#81A1C1", + "identifier": "#D8DEE9", + "plain": "#D8DEE9" + }, + "editor": { + "gutter_bg": "#2E3440", + "gutter_text": "#616E88", + "diag_error": "#BF616A", + "diag_warning": "#D08770", + "suggestion": "#EBCB8B", + "conflict": "#BF616A", + "conflict_line": "#BF616AB4", + "fold_indicator": "#616E88", + "line_highlight": "#3B4252", + "selection": "#3B4252", + "annotation_tag_bg": "#3B4252", + "fold_placeholder": "#616E88", + "caret": "#D8DEE9", + "minimap_bg": "#2E3440", + "minimap_line": "#3B4252", + "minimap_line_active": "#4C566A", + "minimap_view": "#4C566A" + }, + "imgui": { + "WindowBg": "#2E3440", + "ChildBg": "#2E3440", + "PopupBg": "#3B4252", + "Border": "#3B4252", + "Text": "#D8DEE9", + "FrameBg": "#3B4252", + "FrameBgHovered": "#4C566A", + "FrameBgActive": "#4C566A", + "TitleBg": "#2E3440", + "TitleBgActive": "#3B4252", + "Header": "#3B4252", + "HeaderHovered": "#4C566A", + "HeaderActive": "#4C566A", + "Tab": "#3B4252", + "TabHovered": "#4C566A", + "TabActive": "#2E3440", + "Button": "#3B4252", + "ButtonHovered": "#4C566A", + "ButtonActive": "#4C566A", + "ScrollbarBg": "#2E3440", + "ScrollbarGrab": "#3B4252", + "ScrollbarGrabHovered": "#4C566A", + "ScrollbarGrabActive": "#4C566A", + "CheckMark": "#D8DEE9", + "SliderGrab": "#4C566A", + "SliderGrabActive": "#5E81AC", + "Separator": "#3B4252", + "SeparatorHovered": "#4C566A", + "SeparatorActive": "#4C566A" + } +} diff --git a/editor/themes/solarized_dark.json b/editor/themes/solarized_dark.json new file mode 100644 index 0000000..47d4cbc --- /dev/null +++ b/editor/themes/solarized_dark.json @@ -0,0 +1,67 @@ +{ + "name": "Solarized Dark", + "syntax": { + "keyword": "#859900", + "string": "#2AA198", + "comment": "#586E75", + "number": "#D33682", + "function": "#268BD2", + "parameter": "#B58900", + "type": "#B58900", + "operator": "#839496", + "punctuation": "#839496", + "builtin": "#6C71C4", + "identifier": "#839496", + "plain": "#839496" + }, + "editor": { + "gutter_bg": "#002B36", + "gutter_text": "#586E75", + "diag_error": "#DC322F", + "diag_warning": "#CB4B16", + "suggestion": "#B58900", + "conflict": "#DC322F", + "conflict_line": "#DC322FB4", + "fold_indicator": "#586E75", + "line_highlight": "#073642", + "selection": "#073642", + "annotation_tag_bg": "#073642", + "fold_placeholder": "#657B83", + "caret": "#93A1A1", + "minimap_bg": "#002B36", + "minimap_line": "#073642", + "minimap_line_active": "#0B3F4A", + "minimap_view": "#0B3F4A" + }, + "imgui": { + "WindowBg": "#002B36", + "ChildBg": "#002B36", + "PopupBg": "#073642", + "Border": "#073642", + "Text": "#839496", + "FrameBg": "#073642", + "FrameBgHovered": "#0B3F4A", + "FrameBgActive": "#0B3F4A", + "TitleBg": "#002B36", + "TitleBgActive": "#073642", + "Header": "#073642", + "HeaderHovered": "#0B3F4A", + "HeaderActive": "#0B3F4A", + "Tab": "#073642", + "TabHovered": "#0B3F4A", + "TabActive": "#002B36", + "Button": "#073642", + "ButtonHovered": "#0B3F4A", + "ButtonActive": "#0B3F4A", + "ScrollbarBg": "#002B36", + "ScrollbarGrab": "#073642", + "ScrollbarGrabHovered": "#0B3F4A", + "ScrollbarGrabActive": "#0B3F4A", + "CheckMark": "#839496", + "SliderGrab": "#586E75", + "SliderGrabActive": "#657B83", + "Separator": "#073642", + "SeparatorHovered": "#0B3F4A", + "SeparatorActive": "#0B3F4A" + } +} diff --git a/editor/themes/solarized_light.json b/editor/themes/solarized_light.json new file mode 100644 index 0000000..f18fef0 --- /dev/null +++ b/editor/themes/solarized_light.json @@ -0,0 +1,67 @@ +{ + "name": "Solarized Light", + "syntax": { + "keyword": "#859900", + "string": "#2AA198", + "comment": "#93A1A1", + "number": "#D33682", + "function": "#268BD2", + "parameter": "#B58900", + "type": "#B58900", + "operator": "#657B83", + "punctuation": "#657B83", + "builtin": "#6C71C4", + "identifier": "#657B83", + "plain": "#657B83" + }, + "editor": { + "gutter_bg": "#FDF6E3", + "gutter_text": "#93A1A1", + "diag_error": "#DC322F", + "diag_warning": "#CB4B16", + "suggestion": "#B58900", + "conflict": "#DC322F", + "conflict_line": "#DC322FB4", + "fold_indicator": "#93A1A1", + "line_highlight": "#EEE8D5", + "selection": "#EEE8D5", + "annotation_tag_bg": "#EEE8D5", + "fold_placeholder": "#93A1A1", + "caret": "#657B83", + "minimap_bg": "#EEE8D5", + "minimap_line": "#DDD6C1", + "minimap_line_active": "#C9C1AE", + "minimap_view": "#C9C1AE" + }, + "imgui": { + "WindowBg": "#FDF6E3", + "ChildBg": "#FDF6E3", + "PopupBg": "#EEE8D5", + "Border": "#DDD6C1", + "Text": "#657B83", + "FrameBg": "#EEE8D5", + "FrameBgHovered": "#DDD6C1", + "FrameBgActive": "#DDD6C1", + "TitleBg": "#FDF6E3", + "TitleBgActive": "#EEE8D5", + "Header": "#EEE8D5", + "HeaderHovered": "#DDD6C1", + "HeaderActive": "#DDD6C1", + "Tab": "#EEE8D5", + "TabHovered": "#DDD6C1", + "TabActive": "#FDF6E3", + "Button": "#EEE8D5", + "ButtonHovered": "#DDD6C1", + "ButtonActive": "#DDD6C1", + "ScrollbarBg": "#FDF6E3", + "ScrollbarGrab": "#DDD6C1", + "ScrollbarGrabHovered": "#C9C1AE", + "ScrollbarGrabActive": "#C9C1AE", + "CheckMark": "#657B83", + "SliderGrab": "#C9C1AE", + "SliderGrabActive": "#B5AD9B", + "Separator": "#DDD6C1", + "SeparatorHovered": "#C9C1AE", + "SeparatorActive": "#B5AD9B" + } +} diff --git a/editor/themes/vscode_dark.json b/editor/themes/vscode_dark.json new file mode 100644 index 0000000..576144d --- /dev/null +++ b/editor/themes/vscode_dark.json @@ -0,0 +1,67 @@ +{ + "name": "VSCode Dark", + "syntax": { + "keyword": "#C586C0", + "string": "#CE9178", + "comment": "#6A9955", + "number": "#B5CEA8", + "function": "#DCDCAA", + "parameter": "#9CDCFE", + "type": "#4EC9B0", + "operator": "#D4D4D4", + "punctuation": "#D4D4D4", + "builtin": "#4FC1FF", + "identifier": "#D4D4D4", + "plain": "#D4D4D4" + }, + "editor": { + "gutter_bg": "#1E1E1E", + "gutter_text": "#858585", + "diag_error": "#F44747", + "diag_warning": "#FF8800", + "suggestion": "#FFCC00", + "conflict": "#F44747", + "conflict_line": "#F44747B4", + "fold_indicator": "#C5C5C5", + "line_highlight": "#2A2D2E", + "selection": "#264F78", + "annotation_tag_bg": "#333333", + "fold_placeholder": "#808080", + "caret": "#AEAFAD", + "minimap_bg": "#2D2D2D", + "minimap_line": "#3F3F3F", + "minimap_line_active": "#6A6A6A", + "minimap_view": "#3A6EA5" + }, + "imgui": { + "WindowBg": "#1E1E1E", + "ChildBg": "#1E1E1E", + "PopupBg": "#252526", + "Border": "#3C3C3C", + "Text": "#D4D4D4", + "FrameBg": "#2D2D2D", + "FrameBgHovered": "#3A3D41", + "FrameBgActive": "#3A3D41", + "TitleBg": "#1E1E1E", + "TitleBgActive": "#252526", + "Header": "#2D2D2D", + "HeaderHovered": "#3A3D41", + "HeaderActive": "#3A3D41", + "Tab": "#2D2D2D", + "TabHovered": "#3A3D41", + "TabActive": "#1E1E1E", + "Button": "#3A3D41", + "ButtonHovered": "#4B4F55", + "ButtonActive": "#4B4F55", + "ScrollbarBg": "#252526", + "ScrollbarGrab": "#3A3D41", + "ScrollbarGrabHovered": "#4B4F55", + "ScrollbarGrabActive": "#4B4F55", + "CheckMark": "#D4D4D4", + "SliderGrab": "#5A5A5A", + "SliderGrabActive": "#6A6A6A", + "Separator": "#3C3C3C", + "SeparatorHovered": "#5A5A5A", + "SeparatorActive": "#6A6A6A" + } +} diff --git a/editor/themes/vscode_light.json b/editor/themes/vscode_light.json new file mode 100644 index 0000000..3be075e --- /dev/null +++ b/editor/themes/vscode_light.json @@ -0,0 +1,67 @@ +{ + "name": "VSCode Light", + "syntax": { + "keyword": "#0000FF", + "string": "#A31515", + "comment": "#008000", + "number": "#098658", + "function": "#795E26", + "parameter": "#001080", + "type": "#267F99", + "operator": "#000000", + "punctuation": "#000000", + "builtin": "#267F99", + "identifier": "#000000", + "plain": "#000000" + }, + "editor": { + "gutter_bg": "#FFFFFF", + "gutter_text": "#999999", + "diag_error": "#E51400", + "diag_warning": "#CA5010", + "suggestion": "#C19C00", + "conflict": "#E51400", + "conflict_line": "#E51400B4", + "fold_indicator": "#888888", + "line_highlight": "#F3F3F3", + "selection": "#ADD6FF", + "annotation_tag_bg": "#EFEFEF", + "fold_placeholder": "#666666", + "caret": "#000000", + "minimap_bg": "#F3F3F3", + "minimap_line": "#E0E0E0", + "minimap_line_active": "#C8C8C8", + "minimap_view": "#C8C8C8" + }, + "imgui": { + "WindowBg": "#FFFFFF", + "ChildBg": "#FFFFFF", + "PopupBg": "#F3F3F3", + "Border": "#DDDDDD", + "Text": "#000000", + "FrameBg": "#F3F3F3", + "FrameBgHovered": "#E0E0E0", + "FrameBgActive": "#E0E0E0", + "TitleBg": "#FFFFFF", + "TitleBgActive": "#F3F3F3", + "Header": "#F3F3F3", + "HeaderHovered": "#E0E0E0", + "HeaderActive": "#E0E0E0", + "Tab": "#F3F3F3", + "TabHovered": "#E0E0E0", + "TabActive": "#FFFFFF", + "Button": "#E0E0E0", + "ButtonHovered": "#D0D0D0", + "ButtonActive": "#D0D0D0", + "ScrollbarBg": "#FFFFFF", + "ScrollbarGrab": "#D0D0D0", + "ScrollbarGrabHovered": "#B0B0B0", + "ScrollbarGrabActive": "#B0B0B0", + "CheckMark": "#000000", + "SliderGrab": "#B0B0B0", + "SliderGrabActive": "#909090", + "Separator": "#DDDDDD", + "SeparatorHovered": "#C0C0C0", + "SeparatorActive": "#C0C0C0" + } +} diff --git a/sprint5_plan.md b/sprint5_plan.md index 9a14753..f46d439 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -341,7 +341,7 @@ primitives, and assist with constructive coding. Final polish, documentation, and ecosystem features. -- [ ] **Step 160: Theme engine** +- [x] **Step 160: Theme engine** Full theme system beyond Dark/Light. Load themes from JSON files. Theme affects: editor colors, syntax highlighting colors, gutter colors, annotation marker colors, panel backgrounds. Ship with: VSCode Dark,