Step 173: theme gallery UI
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ThemeDefinition> themes_;
|
||||
std::vector<std::string> watchedDirs_;
|
||||
std::unordered_map<std::string, std::filesystem::file_time_type> fileTimes_;
|
||||
|
||||
@@ -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<const char*> 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();
|
||||
|
||||
20
editor/tests/step173_integration_test.cpp
Normal file
20
editor/tests/step173_integration_test.cpp
Normal file
@@ -0,0 +1,20 @@
|
||||
// Step 173: Theme gallery integration checks.
|
||||
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
|
||||
#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;
|
||||
}
|
||||
30
editor/tests/step173_test.cpp
Normal file
30
editor/tests/step173_test.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
// Step 173: Theme gallery UI checks.
|
||||
|
||||
#include <cassert>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
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<char>(f)),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user