Step 171: theme engine core
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,17 +8,43 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <nlohmann/json.hpp>
|
||||
#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<std::string, ImU32> syntaxColors;
|
||||
std::unordered_map<std::string, ImU32> editorColors;
|
||||
std::unordered_map<int, ImVec4> 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<ThemeDefinition> themes_;
|
||||
std::vector<std::string> watchedDirs_;
|
||||
std::unordered_map<std::string, std::filesystem::file_time_type> fileTimes_;
|
||||
int current_ = -1;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
editor/tests/step171_integration_test.cpp
Normal file
34
editor/tests/step171_integration_test.cpp
Normal file
@@ -0,0 +1,34 @@
|
||||
// Step 171: Theme engine integration 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() {
|
||||
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;
|
||||
}
|
||||
68
editor/tests/step171_test.cpp
Normal file
68
editor/tests/step171_test.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
// Step 171: Theme engine core checks.
|
||||
|
||||
#include <cassert>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user