diff --git a/PROGRESS.md b/PROGRESS.md index cdef5d5..2cfb2be 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -520,3 +520,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 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. | | 2026-02-10 | Codex | Step 172: Bundled theme pack renamed to Whetstone Dark/Light and Monokai Pro with tests. 2/2 tests pass (step172_test, step172_integration_test). file_limits_test 4/4 passes. | +| 2026-02-10 | Codex | Step 173: Theme gallery UI with hover preview, apply/reset, and swatches. 2/2 tests pass (step173_test, step173_integration_test). file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 4955c38..7d3a179 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -995,6 +995,13 @@ add_executable(step172_integration_test tests/step172_integration_test.cpp) target_include_directories(step172_integration_test PRIVATE src) target_link_libraries(step172_integration_test PRIVATE imgui::imgui) +add_executable(step173_test tests/step173_test.cpp) +target_include_directories(step173_test PRIVATE src) + +add_executable(step173_integration_test tests/step173_integration_test.cpp) +target_include_directories(step173_integration_test PRIVATE src) +target_link_libraries(step173_integration_test PRIVATE imgui::imgui) + 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 ab40b30..4069df7 100644 --- a/editor/src/ThemeEngine.h +++ b/editor/src/ThemeEngine.h @@ -136,14 +136,7 @@ public: 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; + return colorFromTheme(*theme, color, fallback); } std::string currentThemeName() const { @@ -166,6 +159,16 @@ public: return it != theme->editorColors.end() ? it->second : fallback; } + bool getSwatch(const std::string& name, ImU32 outColors[4]) const { + const ThemeDefinition* theme = findThemeByName(name); + if (!theme) return false; + outColors[0] = colorFromTheme(*theme, ThemeColor::EditorBg, IM_COL32(30, 30, 30, 255)); + outColors[1] = colorFromTheme(*theme, ThemeColor::SyntaxKeyword, IM_COL32(197, 134, 192, 255)); + outColors[2] = colorFromTheme(*theme, ThemeColor::SyntaxString, IM_COL32(206, 145, 120, 255)); + outColors[3] = colorFromTheme(*theme, ThemeColor::SyntaxComment, IM_COL32(106, 153, 85, 255)); + return true; + } + bool refreshWatchedThemes() { namespace fs = std::filesystem; bool reloaded = false; @@ -209,6 +212,13 @@ private: return &themes_[current_]; } + const ThemeDefinition* findThemeByName(const std::string& name) const { + for (const auto& theme : themes_) { + if (theme.name == name) return &theme; + } + return nullptr; + } + static bool parseHexColor(const std::string& text, ImU32& outU32, ImVec4& outVec) { @@ -351,6 +361,19 @@ private: } } + static ImU32 colorFromTheme(const ThemeDefinition& theme, + ThemeColor color, + ImU32 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::vector themes_; std::vector watchedDirs_; std::unordered_map fileTimes_; diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index a9ceae8..6c77a81 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -36,29 +36,114 @@ static void renderSettingsPanel(EditorState& state) { if (themeNames.empty()) { themeNames = {"Whetstone Dark", "Whetstone Light"}; } - std::string currentTheme = state.settings.getTheme(); - if (currentTheme == "Dark") currentTheme = "Whetstone Dark"; - if (currentTheme == "Light") currentTheme = "Whetstone Light"; - int themeIndex = 0; - for (size_t i = 0; i < themeNames.size(); ++i) { - if (themeNames[i] == currentTheme) { - themeIndex = (int)i; + std::string savedTheme = state.settings.getTheme(); + if (savedTheme == "Dark") savedTheme = "Whetstone Dark"; + if (savedTheme == "Light") savedTheme = "Whetstone Light"; + + static std::string selectedTheme; + static std::string previewAnchor; + static bool previewActive = false; + if (selectedTheme.empty()) selectedTheme = savedTheme; + bool selectedExists = false; + for (const auto& name : themeNames) { + if (name == selectedTheme) { + selectedExists = true; 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(); + if (!selectedExists) selectedTheme = savedTheme; + + if (ImGui::CollapsingHeader("Themes", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::TextDisabled("Hover to preview, click to select."); + const float cardWidth = 180.0f; + const float cardHeight = 90.0f; + const float padding = 8.0f; + float avail = ImGui::GetContentRegionAvail().x; + int columns = std::max(1, (int)(avail / (cardWidth + padding))); + + bool anyHovered = false; + std::string hoveredTheme; + + if (ImGui::BeginTable("##themeGallery", columns, ImGuiTableFlags_SizingFixedFit)) { + for (const auto& name : themeNames) { + ImGui::TableNextColumn(); + ImGui::PushID(name.c_str()); + ImGui::BeginChild("##themeCard", ImVec2(cardWidth, cardHeight), true); + ImGui::TextUnformatted(name.c_str()); + if (name == selectedTheme) { + ImGui::SameLine(); + ImGui::TextDisabled("Selected"); + } + + ImU32 swatch[4] = { + IM_COL32(40, 40, 40, 255), + IM_COL32(197, 134, 192, 255), + IM_COL32(206, 145, 120, 255), + IM_COL32(106, 153, 85, 255) + }; + ThemeEngine::instance().getSwatch(name, swatch); + ImVec2 start = ImGui::GetCursorScreenPos(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + float swatchWidth = (cardWidth - 2.0f * padding) / 4.0f; + float swatchHeight = 24.0f; + for (int i = 0; i < 4; ++i) { + ImVec2 a(start.x + i * swatchWidth, start.y); + ImVec2 b(start.x + (i + 1) * swatchWidth - 2.0f, start.y + swatchHeight); + draw->AddRectFilled(a, b, swatch[i], 3.0f); + } + ImGui::Dummy(ImVec2(0.0f, swatchHeight + 4.0f)); + ImGui::EndChild(); + + if (ImGui::IsItemHovered()) { + anyHovered = true; + hoveredTheme = name; + } + if (ImGui::IsItemClicked()) { + selectedTheme = name; + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + if (anyHovered) { + if (!previewActive) { + previewAnchor = savedTheme; + previewActive = true; + } + if (!hoveredTheme.empty() && + ThemeEngine::instance().currentThemeName() != hoveredTheme) { + ThemeEngine::instance().applyTheme(hoveredTheme); + } + } else if (previewActive) { + ThemeEngine::instance().applyTheme(previewAnchor); + previewActive = false; + } + + ImGui::Separator(); + bool canApply = !selectedTheme.empty() && selectedTheme != savedTheme; + if (!canApply) ImGui::BeginDisabled(); + if (ImGui::Button("Apply")) { + state.settings.setTheme(selectedTheme); + if (!ThemeEngine::instance().applyTheme(selectedTheme)) { + if (selectedTheme.find("Light") != std::string::npos) + SetupVSCodeLightTheme(); + else + SetupVSCodeDarkTheme(); + } + previewActive = false; + previewAnchor = selectedTheme; + settingsChanged = true; + themeChanged = true; + savedTheme = selectedTheme; + } + if (!canApply) ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + selectedTheme = savedTheme; + ThemeEngine::instance().applyTheme(savedTheme); + previewActive = false; } - settingsChanged = true; - themeChanged = true; } bool telemetryOptIn = state.settings.getTelemetryOptIn(); diff --git a/editor/tests/step173_integration_test.cpp b/editor/tests/step173_integration_test.cpp new file mode 100644 index 0000000..95a5495 --- /dev/null +++ b/editor/tests/step173_integration_test.cpp @@ -0,0 +1,20 @@ +// Step 173: Theme gallery integration checks. + +#include +#include + +#include "ThemeEngine.h" + +int main() { + ImGui::CreateContext(); + ThemeEngine& themes = ThemeEngine::instance(); + themes.clear(); + themes.loadThemesFromDirectory("themes"); + ImU32 swatch[4] = {}; + bool ok = themes.getSwatch("Whetstone Dark", swatch); + assert(ok); + ImGui::DestroyContext(); + + printf("step173_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step173_test.cpp b/editor/tests/step173_test.cpp new file mode 100644 index 0000000..53e1d46 --- /dev/null +++ b/editor/tests/step173_test.cpp @@ -0,0 +1,30 @@ +// Step 173: Theme gallery UI 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() { + const std::string settingsPanel = readFile("src/panels/SettingsPanel.h"); + assertContains(settingsPanel, "themeGallery"); + assertContains(settingsPanel, "Hover to preview"); + assertContains(settingsPanel, "Apply"); + assertContains(settingsPanel, "Reset"); + + printf("step173_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 25c913c..cf124e3 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -113,7 +113,7 @@ look professional and feel personal. Each theme tested with all annotation types visible. *New:* 7 theme JSON files -- [ ] **Step 173: Theme gallery UI** +- [x] **Step 173: Theme gallery UI** Settings > Themes section with visual browser: - Grid of theme cards showing name + color swatch preview - Live preview on hover (entire editor updates temporarily)