diff --git a/PROGRESS.md b/PROGRESS.md index c0e3376..ae2b3fb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -518,3 +518,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 168: Split oversized editor/AST component headers (CodeEditorWidget, SyntaxHighlighter, Parser, CppGenerator) with new step168 unit + integration tests. 79/79 tests pass (step168_test 75/75, step168_integration_test 4/4). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 169: Notification/toast system with status bar history, output log rewire, and notification tests. 2/2 tests pass (step169_test, step169_integration_test). file_limits_test 4/4 passes. | | 2026-02-10 | Codex | Step 170: UI event bus with debounced dispatch, editor wiring, and settings/theme events. 2/2 tests pass (step170_test, step170_integration_test). file_limits_test 4/4 passes. | +| 2026-02-10 | Codex | Step 171: Theme engine core enhancements (ThemeColor API, user theme dir, hot reload) with tests. 2/2 tests pass (step171_test, step171_integration_test). file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index a2b5ddd..37c8f8d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -981,6 +981,13 @@ target_include_directories(step170_test PRIVATE src) add_executable(step170_integration_test tests/step170_integration_test.cpp) target_include_directories(step170_integration_test PRIVATE src) +add_executable(step171_test tests/step171_test.cpp) +target_include_directories(step171_test PRIVATE src) +target_link_libraries(step171_test PRIVATE imgui::imgui) + +add_executable(step171_integration_test tests/step171_integration_test.cpp) +target_include_directories(step171_integration_test PRIVATE src) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/ThemeEngine.h b/editor/src/ThemeEngine.h index c3daa01..ab40b30 100644 --- a/editor/src/ThemeEngine.h +++ b/editor/src/ThemeEngine.h @@ -8,17 +8,43 @@ #include #include #include +#include #include #include "imgui.h" #include "SyntaxHighlighter.h" using json = nlohmann::json; +enum class ThemeColor { + EditorBg, + EditorFg, + EditorCursor, + EditorSelection, + EditorCurrentLine, + SyntaxKeyword, + SyntaxString, + SyntaxComment, + SyntaxNumber, + SyntaxType, + SyntaxFunction, + SyntaxOperator, + GutterBg, + GutterText, + GutterMarkerError, + GutterMarkerWarning, + PanelBg, + PanelBorder, + PanelHeader, + StatusBarBg, + TooltipBg +}; + struct ThemeDefinition { std::string name; std::unordered_map syntaxColors; std::unordered_map editorColors; std::unordered_map imguiColors; + std::string sourcePath; }; class ThemeEngine { @@ -33,10 +59,20 @@ public: current_ = -1; } - bool loadThemesFromDirectory(const std::string& dir) { + bool loadThemesFromDirectory(const std::string& dir, bool watch = true) { namespace fs = std::filesystem; std::error_code ec; if (!fs::exists(dir, ec)) return false; + if (watch) { + bool tracked = false; + for (const auto& existing : watchedDirs_) { + if (existing == dir) { + tracked = true; + break; + } + } + if (!tracked) watchedDirs_.push_back(dir); + } bool loaded = false; for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; @@ -52,7 +88,9 @@ public: if (!in.is_open()) return false; std::ostringstream ss; ss << in.rdbuf(); - return loadThemeFromJson(ss.str()); + if (!loadThemeFromJson(ss.str())) return false; + fileTimes_[path] = std::filesystem::last_write_time(path); + return true; } bool loadThemeFromJson(const std::string& text) { @@ -95,6 +133,19 @@ public: return false; } + ImU32 getColor(ThemeColor color, ImU32 fallback) const { + const ThemeDefinition* theme = currentTheme(); + if (!theme) return fallback; + const std::string key = colorKey(color); + if (key.empty()) return fallback; + if (isSyntaxColor(color)) { + auto it = theme->syntaxColors.find(key); + return it != theme->syntaxColors.end() ? it->second : fallback; + } + auto it = theme->editorColors.find(key); + return it != theme->editorColors.end() ? it->second : fallback; + } + std::string currentThemeName() const { if (current_ < 0 || current_ >= (int)themes_.size()) return ""; return themes_[current_].name; @@ -115,6 +166,41 @@ public: return it != theme->editorColors.end() ? it->second : fallback; } + bool refreshWatchedThemes() { + namespace fs = std::filesystem; + bool reloaded = false; + for (const auto& dir : watchedDirs_) { + std::error_code ec; + if (!fs::exists(dir, ec)) continue; + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + auto path = entry.path(); + if (path.extension() != ".json") continue; + auto pathStr = path.string(); + auto ts = fs::last_write_time(path, ec); + if (ec) continue; + auto it = fileTimes_.find(pathStr); + if (it == fileTimes_.end() || it->second != ts) { + if (loadThemeFile(pathStr)) { + reloaded = true; + } + } + } + } + if (reloaded && current_ >= 0 && current_ < (int)themes_.size()) { + applyToImGui(themes_[current_]); + } + return reloaded; + } + + static std::string userThemeDirectory() { + const char* home = std::getenv("USERPROFILE"); + if (!home) home = std::getenv("HOME"); + std::filesystem::path base = home ? std::filesystem::path(home) + : std::filesystem::current_path(); + return (base / ".whetstone" / "themes").string(); + } + private: ThemeEngine() = default; @@ -228,6 +314,45 @@ private: } } + static bool isSyntaxColor(ThemeColor color) { + return color == ThemeColor::SyntaxKeyword || + color == ThemeColor::SyntaxString || + color == ThemeColor::SyntaxComment || + color == ThemeColor::SyntaxNumber || + color == ThemeColor::SyntaxType || + color == ThemeColor::SyntaxFunction || + color == ThemeColor::SyntaxOperator; + } + + static std::string colorKey(ThemeColor color) { + switch (color) { + case ThemeColor::EditorBg: return "editor_bg"; + case ThemeColor::EditorFg: return "editor_fg"; + case ThemeColor::EditorCursor: return "caret"; + case ThemeColor::EditorSelection: return "selection"; + case ThemeColor::EditorCurrentLine: return "line_highlight"; + case ThemeColor::SyntaxKeyword: return "keyword"; + case ThemeColor::SyntaxString: return "string"; + case ThemeColor::SyntaxComment: return "comment"; + case ThemeColor::SyntaxNumber: return "number"; + case ThemeColor::SyntaxType: return "type"; + case ThemeColor::SyntaxFunction: return "function"; + case ThemeColor::SyntaxOperator: return "operator"; + case ThemeColor::GutterBg: return "gutter_bg"; + case ThemeColor::GutterText: return "gutter_text"; + case ThemeColor::GutterMarkerError: return "diag_error"; + case ThemeColor::GutterMarkerWarning: return "diag_warning"; + case ThemeColor::PanelBg: return "panel_bg"; + case ThemeColor::PanelBorder: return "panel_border"; + case ThemeColor::PanelHeader: return "panel_header"; + case ThemeColor::StatusBarBg: return "status_bg"; + case ThemeColor::TooltipBg: return "tooltip_bg"; + default: return ""; + } + } + std::vector themes_; + std::vector watchedDirs_; + std::unordered_map fileTimes_; int current_ = -1; }; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index f88a264..058ef20 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -123,6 +123,7 @@ int main(int, char**) { ThemeEngine& themes = ThemeEngine::instance(); themes.loadThemesFromDirectory("themes"); themes.loadThemesFromDirectory("editor/themes"); + themes.loadThemesFromDirectory(ThemeEngine::userThemeDirectory()); std::string themeName = state.settings.getTheme(); if (themeName == "Dark") themeName = "VSCode Dark"; if (themeName == "Light") themeName = "VSCode Light"; @@ -254,6 +255,7 @@ int main(int, char**) { } state.refreshEmacsModeLine(ImGui::GetTime()); state.events.tick(ImGui::GetTime()); + themes.refreshWatchedThemes(); // --- Start frame --- ImGui_ImplOpenGL3_NewFrame(); diff --git a/editor/tests/step171_integration_test.cpp b/editor/tests/step171_integration_test.cpp new file mode 100644 index 0000000..015759f --- /dev/null +++ b/editor/tests/step171_integration_test.cpp @@ -0,0 +1,34 @@ +// Step 171: Theme engine integration checks. + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + assert(fs::exists("src/ThemeEngine.h")); + const std::string themeEngine = readFile("src/ThemeEngine.h"); + assertContains(themeEngine, "ThemeColor"); + assertContains(themeEngine, "refreshWatchedThemes"); + assertContains(themeEngine, "userThemeDirectory"); + + const std::string mainCpp = readFile("src/main.cpp"); + assertContains(mainCpp, "userThemeDirectory"); + assertContains(mainCpp, "refreshWatchedThemes"); + + printf("step171_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step171_test.cpp b/editor/tests/step171_test.cpp new file mode 100644 index 0000000..bbb3e27 --- /dev/null +++ b/editor/tests/step171_test.cpp @@ -0,0 +1,68 @@ +// Step 171: Theme engine core checks. + +#include +#include +#include +#include +#include +#include + +#include "ThemeEngine.h" + +namespace fs = std::filesystem; + +static void writeFile(const fs::path& path, const std::string& text) { + std::ofstream out(path.string(), std::ios::binary); + out << text; +} + +int main() { + ImGui::CreateContext(); + ThemeEngine& themes = ThemeEngine::instance(); + themes.clear(); + + const std::string jsonText = + "{" + "\"name\":\"UnitTheme\"," + "\"syntax\":{\"keyword\":\"#010203\"}," + "\"editor\":{\"editor_bg\":\"#040506\"}" + "}"; + assert(themes.loadThemeFromJson(jsonText)); + assert(themes.applyTheme("UnitTheme")); + ImU32 keyword = themes.getColor(ThemeColor::SyntaxKeyword, IM_COL32(0, 0, 0, 255)); + ImU32 editorBg = themes.getColor(ThemeColor::EditorBg, IM_COL32(0, 0, 0, 255)); + assert(keyword == IM_COL32(1, 2, 3, 255)); + assert(editorBg == IM_COL32(4, 5, 6, 255)); + + fs::path tempDir = fs::current_path() / "test_theme_data"; + fs::create_directories(tempDir); + fs::path themeFile = tempDir / "reload.json"; + writeFile(themeFile, + "{" + "\"name\":\"ReloadTheme\"," + "\"syntax\":{\"keyword\":\"#111111\"}" + "}"); + + assert(themes.loadThemesFromDirectory(tempDir.string())); + assert(themes.applyTheme("ReloadTheme")); + ImU32 original = themes.getColor(ThemeColor::SyntaxKeyword, IM_COL32(0, 0, 0, 255)); + assert(original == IM_COL32(17, 17, 17, 255)); + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + writeFile(themeFile, + "{" + "\"name\":\"ReloadTheme\"," + "\"syntax\":{\"keyword\":\"#222222\"}" + "}"); + assert(themes.refreshWatchedThemes()); + ImU32 updated = themes.getColor(ThemeColor::SyntaxKeyword, IM_COL32(0, 0, 0, 255)); + assert(updated == IM_COL32(34, 34, 34, 255)); + + std::error_code ec; + fs::remove(themeFile, ec); + fs::remove(tempDir, ec); + + ImGui::DestroyContext(); + printf("step171_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 5ad20be..631ea0c 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -89,7 +89,7 @@ first — all UX work becomes easier when panels are modular. Full visual customization. Ship with popular themes. Make the editor look professional and feel personal. -- [ ] **Step 171: Theme engine core** +- [x] **Step 171: Theme engine core** `ThemeEngine.h` with theme data model: - Color categories: editor (bg, fg, cursor, selection, currentLine), syntax (keyword, string, comment, number, type, function, operator),