diff --git a/PROGRESS.md b/PROGRESS.md index 59d7783..c0e3376 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -517,3 +517,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 167: Split EditorState into focused sub-states (Search/Agent/Build/Library/Emacs/UI), updated panels/handlers, and added step167_test. 1/1 tests pass. | | 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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 7fc1476..a2b5ddd 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -975,6 +975,12 @@ add_executable(step169_integration_test tests/step169_integration_test.cpp) target_include_directories(step169_integration_test PRIVATE src) target_link_libraries(step169_integration_test PRIVATE imgui::imgui) +add_executable(step170_test tests/step170_test.cpp) +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) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index e52bdc8..dcdb006 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -50,6 +50,7 @@ #include "Telemetry.h" #include "UpdateChecker.h" #include "NotificationSystem.h" +#include "UIEventBus.h" #include "state/SearchState.h" #include "state/AgentState.h" #include "state/BuildState.h" @@ -174,6 +175,7 @@ struct EditorState { UIFlags ui; NotificationSystem notifications; + UIEventBus events; HelpPanelState helpPanel; Telemetry telemetry; char outlineFilter[128] = {}; @@ -328,10 +330,12 @@ struct EditorState { void notify(NotificationLevel level, const std::string& message) { notifications.notify(level, message); + events.publish(UIEventType::NotificationPosted, {}, message, ImGui::GetTime()); } void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) { notifications.notify(level, message, target); + events.publish(UIEventType::NotificationPosted, target.path, message, ImGui::GetTime()); } void navigateToTarget(const NotificationTarget& target) { @@ -445,8 +449,6 @@ struct EditorState { activeBuffer = state.get(); bufferStates[path] = std::move(state); active()->orchestratorDirty = true; - library.primitives.setRoot(activeAST()); - library.primitives.setLanguage(active()->language); recordUndoSnapshot(); if (language == "elisp") { emacsState.emacsFunctionIndexDirty = true; @@ -455,9 +457,7 @@ struct EditorState { if (lsp && path.rfind("(untitled", 0) != 0) { lsp->didOpen(toFileUri(path), language, content, activeBuffer->lspVersion); } - symbolsPending = true; - symbolsLastChange = ImGui::GetTime(); - if (lsp) lsp->clearDocumentSymbols(); + events.publish(UIEventType::BufferSwitched, path, {}, ImGui::GetTime()); } bool jumpToDefinitionLocation(const LSPClient::DefinitionLocation& loc) { @@ -538,14 +538,7 @@ struct EditorState { buffers.switchToBuffer(path); activeBuffer = bufferStates[path].get(); if (active()) active()->orchestratorDirty = true; - library.primitives.setRoot(activeAST()); - library.primitives.setLanguage(active()->language); - symbolsPending = true; - symbolsLastChange = ImGui::GetTime(); - if (lsp) lsp->clearDocumentSymbols(); - if (active() && active()->language == "elisp") { - emacsState.emacsFunctionIndexDirty = true; - } + events.publish(UIEventType::BufferSwitched, path, {}, ImGui::GetTime()); } std::filesystem::path configDir() const { @@ -787,6 +780,28 @@ struct EditorState { loadRecentFiles(); loadSettingsFromDisk(); registerCommands(); + events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent&) { + if (!active()) return; + library.primitives.setRoot(activeAST()); + library.primitives.setLanguage(active()->language); + symbolsPending = true; + symbolsLastChange = ImGui::GetTime(); + if (lsp) lsp->clearDocumentSymbols(); + if (active()->language == "elisp") { + emacsState.emacsFunctionIndexDirty = true; + } + }); + events.subscribe(UIEventType::ASTChanged, [this](const UIEvent&) { + if (!active()) return; + library.primitives.setRoot(activeAST()); + library.primitives.setLanguage(active()->language); + symbolsPending = true; + symbolsLastChange = ImGui::GetTime(); + if (lsp) lsp->clearDocumentSymbols(); + }); + events.subscribe(UIEventType::SettingsChanged, [this](const UIEvent&) { + applySettingsToState(); + }); startEmacsDaemonFromSettings(); initAgentServer(); refreshBuildSystem(); @@ -1671,6 +1686,9 @@ struct EditorState { std::string prevLang = active()->language; active()->language = lang; active()->mode.setLanguage(lang); + if (activeAST()) { + library.primitives.setRoot(activeAST()); + } library.primitives.setLanguage(lang); if (active()->generatedLanguage == prevLang) { active()->generatedLanguage = lang; @@ -1692,6 +1710,11 @@ struct EditorState { active()->bufferMode = BufferManager::BufferMode::Text; buffers.setBufferMode(active()->path, active()->bufferMode); } + events.publishDebounced(UIEventType::ASTChanged, + active()->path, + {}, + ImGui::GetTime(), + 0.1); } bool handleEmacsKeyChord(const std::string& chord) { @@ -1823,19 +1846,20 @@ struct EditorState { active()->sync.syncNow(); active()->incrementalOptimizer.setRoot(active()->sync.getAST()); } - library.primitives.setRoot(activeAST()); - library.primitives.setLanguage(active()->language); active()->orchestratorDirty = true; active()->highlightsDirty = true; active()->generatedHighlightsDirty = true; active()->modified = true; - symbolsPending = true; - symbolsLastChange = ImGui::GetTime(); - if (lsp) lsp->clearDocumentSymbols(); if (lsp && active()->path.rfind("(untitled", 0) != 0) { active()->lspVersion += 1; lsp->didChange(toFileUri(active()->path), active()->editBuf, active()->lspVersion); } + events.publish(UIEventType::FileModified, active()->path, {}, ImGui::GetTime()); + events.publishDebounced(UIEventType::ASTChanged, + active()->path, + {}, + ImGui::GetTime(), + 0.15); recordUndoSnapshot(); } diff --git a/editor/src/UIEventBus.h b/editor/src/UIEventBus.h new file mode 100644 index 0000000..ff77829 --- /dev/null +++ b/editor/src/UIEventBus.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include + +enum class UIEventType { + ASTChanged, + BufferSwitched, + DiagnosticsUpdated, + ThemeChanged, + SettingsChanged, + FileModified, + AgentConnected, + NotificationPosted +}; + +struct UIEvent { + UIEventType type = UIEventType::ASTChanged; + std::string path; + std::string detail; + double timestamp = 0.0; +}; + +class UIEventBus { +public: + using Handler = std::function; + + int subscribe(UIEventType type, Handler handler) { + int id = ++nextId; + handlers[keyFor(type)].push_back({id, std::move(handler)}); + return id; + } + + void publish(UIEventType type, + const std::string& path = {}, + const std::string& detail = {}, + double nowSeconds = 0.0) { + UIEvent ev; + ev.type = type; + ev.path = path; + ev.detail = detail; + ev.timestamp = nowSeconds; + dispatch(ev); + } + + void publishDebounced(UIEventType type, + const std::string& path, + const std::string& detail, + double nowSeconds, + double delaySeconds) { + PendingEvent& pendingEvent = pending[keyFor(type)]; + pendingEvent.event.type = type; + pendingEvent.event.path = path; + pendingEvent.event.detail = detail; + pendingEvent.event.timestamp = nowSeconds; + pendingEvent.readyAt = nowSeconds + delaySeconds; + } + + void tick(double nowSeconds) { + std::vector ready; + ready.reserve(pending.size()); + for (const auto& kv : pending) { + if (nowSeconds >= kv.second.readyAt) { + ready.push_back(kv.first); + } + } + for (int key : ready) { + auto it = pending.find(key); + if (it == pending.end()) continue; + dispatch(it->second.event); + pending.erase(it); + } + } + +private: + struct HandlerEntry { + int id = 0; + Handler fn; + }; + + struct PendingEvent { + UIEvent event; + double readyAt = 0.0; + }; + + int nextId = 0; + std::unordered_map> handlers; + std::unordered_map pending; + + static int keyFor(UIEventType type) { + return static_cast(type); + } + + void dispatch(const UIEvent& ev) { + auto it = handlers.find(keyFor(ev.type)); + if (it == handlers.end()) return; + for (const auto& entry : it->second) { + if (entry.fn) entry.fn(ev); + } + } +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index b78b031..f88a264 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -253,6 +253,7 @@ int main(int, char**) { state.updateEmacsFunctionIndex(); } state.refreshEmacsModeLine(ImGui::GetTime()); + state.events.tick(ImGui::GetTime()); // --- Start frame --- ImGui_ImplOpenGL3_NewFrame(); diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index 9fb4a64..f939a28 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -9,6 +9,7 @@ static void renderSettingsPanel(EditorState& state) { ImGui::PushFont(state.uiFont); bool settingsChanged = false; bool emacsConfigChanged = false; + bool themeChanged = false; ImGuiIO& io = ImGui::GetIO(); int fontSize = state.settings.getFontSize(); @@ -57,6 +58,7 @@ static void renderSettingsPanel(EditorState& state) { SetupVSCodeDarkTheme(); } settingsChanged = true; + themeChanged = true; } bool telemetryOptIn = state.settings.getTelemetryOptIn(); @@ -158,10 +160,14 @@ static void renderSettingsPanel(EditorState& state) { if (settingsChanged) { state.saveSettingsToDisk(); + state.events.publish(UIEventType::SettingsChanged, {}, {}, ImGui::GetTime()); } if (emacsConfigChanged) { state.startEmacsDaemonFromSettings(); } + if (themeChanged) { + state.events.publish(UIEventType::ThemeChanged, {}, {}, ImGui::GetTime()); + } ImGui::PopFont(); ImGui::End(); } diff --git a/editor/tests/step170_integration_test.cpp b/editor/tests/step170_integration_test.cpp new file mode 100644 index 0000000..180da8d --- /dev/null +++ b/editor/tests/step170_integration_test.cpp @@ -0,0 +1,37 @@ +// Step 170: UIEventBus 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/UIEventBus.h")); + + const std::string editorState = readFile("src/EditorState.h"); + assertContains(editorState, "UIEventBus"); + assertContains(editorState, "events"); + + const std::string settingsPanel = readFile("src/panels/SettingsPanel.h"); + assertContains(settingsPanel, "SettingsChanged"); + assertContains(settingsPanel, "ThemeChanged"); + + const std::string mainCpp = readFile("src/main.cpp"); + assertContains(mainCpp, "events.tick"); + + printf("step170_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step170_test.cpp b/editor/tests/step170_test.cpp new file mode 100644 index 0000000..228feff --- /dev/null +++ b/editor/tests/step170_test.cpp @@ -0,0 +1,37 @@ +// Step 170: UIEventBus unit checks. + +#include +#include + +#include "UIEventBus.h" + +int main() { + UIEventBus bus; + int count = 0; + std::string lastDetail; + + bus.subscribe(UIEventType::ASTChanged, [&](const UIEvent& ev) { + ++count; + lastDetail = ev.detail; + }); + + bus.publish(UIEventType::ASTChanged, "file.cpp", "first", 1.0); + assert(count == 1); + assert(lastDetail == "first"); + + bus.publishDebounced(UIEventType::ASTChanged, "file.cpp", "debounce", 2.0, 0.5); + bus.tick(2.3); + assert(count == 1); + bus.tick(2.6); + assert(count == 2); + assert(lastDetail == "debounce"); + + bus.publishDebounced(UIEventType::ASTChanged, "file.cpp", "last", 3.0, 0.5); + bus.publishDebounced(UIEventType::ASTChanged, "file.cpp", "last2", 3.2, 0.5); + bus.tick(3.8); + assert(count == 3); + assert(lastDetail == "last2"); + + printf("step170_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 7398786..5ad20be 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -73,7 +73,7 @@ first — all UX work becomes easier when panels are modular. - Panels use `notify(level, message)` instead of appending to outputLog *New:* `NotificationSystem.h`. *Modifies:* panels that use outputLog -- [ ] **Step 170: UI event bus for decoupled updates** +- [x] **Step 170: UI event bus for decoupled updates** Create `UIEventBus.h` — lightweight pub/sub for UI events: - Events: ASTChanged, BufferSwitched, DiagnosticsUpdated, ThemeChanged, SettingsChanged, FileModified, AgentConnected, NotificationPosted