From 454bd5eb8acf8e8774ebefd08d6d7005a32bc588 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 21:26:10 -0700 Subject: [PATCH] Step 169: notification system --- PROGRESS.md | 1 + editor/CMakeLists.txt | 8 + editor/src/AgentMarketplace.h | 30 +-- editor/src/CompositionPanel.h | 9 +- editor/src/DependencyPanel.h | 97 +++++---- editor/src/EditorState.h | 133 ++++++++----- editor/src/EmacsFunctionDiscovery.h | 17 +- editor/src/EmacsKeybinding.h | 25 ++- editor/src/EmacsPackageBrowser.h | 34 ++-- editor/src/LibraryBrowserPanel.h | 6 +- editor/src/NotificationSystem.h | 230 ++++++++++++++++++++++ editor/src/main.cpp | 6 + editor/src/panels/BottomPanel.h | 45 +++-- editor/src/panels/DialogPanels.h | 5 +- editor/src/panels/EditorPanel.h | 28 +-- editor/src/panels/MenuBarPanel.h | 6 +- editor/src/panels/SettingsPanel.h | 6 +- editor/src/panels/SidePanels.h | 17 +- editor/src/panels/StatusBarPanel.h | 10 + editor/tests/step169_integration_test.cpp | 41 ++++ editor/tests/step169_test.cpp | 48 +++++ sprint6_plan.md | 2 +- 22 files changed, 637 insertions(+), 167 deletions(-) create mode 100644 editor/src/NotificationSystem.h create mode 100644 editor/tests/step169_integration_test.cpp create mode 100644 editor/tests/step169_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index 309ccb7..59d7783 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -516,3 +516,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Added `file_limits_test` to enforce architecture file size limits (with temporary allowlist for known oversize headers). 4/4 tests pass. | | 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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 17b70a1..7fc1476 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -967,6 +967,14 @@ target_link_libraries(step168_integration_test PRIVATE tree_sitter_rust tree_sitter_go) +add_executable(step169_test tests/step169_test.cpp) +target_include_directories(step169_test PRIVATE src) +target_link_libraries(step169_test PRIVATE imgui::imgui) + +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) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/AgentMarketplace.h b/editor/src/AgentMarketplace.h index 46b4397..5ad391c 100644 --- a/editor/src/AgentMarketplace.h +++ b/editor/src/AgentMarketplace.h @@ -3,6 +3,7 @@ #include "imgui.h" #include "AgentRegistry.h" +#include "NotificationSystem.h" #include #include @@ -30,7 +31,7 @@ static bool agentMatchesFilter(const AgentDescriptor& agent, static void renderAgentMarketplace(AgentMarketplaceState& state, AgentRegistry& registry, - std::string& outputLog, + NotificationSystem& notifications, const std::string& workspaceRoot) { if (state.registryPathBuf[0] == '\0') { std::string path = workspaceRoot.empty() ? "agents.json" @@ -46,30 +47,35 @@ static void renderAgentMarketplace(AgentMarketplaceState& state, ImGui::InputText("Registry URL", state.registryUrlBuf, sizeof(state.registryUrlBuf)); if (ImGui::Button("Set URL")) { registry.setRegistryUrl(state.registryUrlBuf); - outputLog += "[agents] Registry URL set.\n"; + notifications.notify(NotificationLevel::Info, "[agents] Registry URL set."); } ImGui::SameLine(); if (ImGui::Button("Refresh")) { - outputLog += "[agents] STUB: refresh registry from " + - std::string(state.registryUrlBuf) + "\n"; + notifications.notify(NotificationLevel::Info, + "[agents] STUB: refresh registry from " + + std::string(state.registryUrlBuf)); } ImGui::InputText("Registry File", state.registryPathBuf, sizeof(state.registryPathBuf)); if (ImGui::Button("Load")) { std::string error; if (!registry.loadFromFile(state.registryPathBuf, error)) { - outputLog += "[agents] Load failed: " + error + "\n"; + notifications.notify(NotificationLevel::Error, + "[agents] Load failed: " + error); } else { - outputLog += "[agents] Loaded registry.\n"; + notifications.notify(NotificationLevel::Success, + "[agents] Loaded registry."); } } ImGui::SameLine(); if (ImGui::Button("Save")) { std::string error; if (!registry.saveToFile(state.registryPathBuf, error)) { - outputLog += "[agents] Save failed: " + error + "\n"; + notifications.notify(NotificationLevel::Error, + "[agents] Save failed: " + error); } else { - outputLog += "[agents] Saved registry.\n"; + notifications.notify(NotificationLevel::Success, + "[agents] Saved registry."); } } @@ -114,13 +120,15 @@ static void renderAgentMarketplace(AgentMarketplaceState& state, if (!selected->installed) { if (ImGui::Button("Install Agent")) { selected->installed = true; - outputLog += "[agents] Installed " + selected->name + - " (" + selected->endpoint + ")\n"; + notifications.notify(NotificationLevel::Success, + "[agents] Installed " + selected->name + + " (" + selected->endpoint + ")"); } } else { if (ImGui::Button("Uninstall Agent")) { selected->installed = false; - outputLog += "[agents] Uninstalled " + selected->name + "\n"; + notifications.notify(NotificationLevel::Success, + "[agents] Uninstalled " + selected->name); } } } diff --git a/editor/src/CompositionPanel.h b/editor/src/CompositionPanel.h index 33fa7d7..0826c18 100644 --- a/editor/src/CompositionPanel.h +++ b/editor/src/CompositionPanel.h @@ -1,6 +1,7 @@ #pragma once #include "imgui.h" #include "CompositionBuilder.h" +#include "NotificationSystem.h" #include #include #include @@ -17,7 +18,7 @@ struct CompositionPanelState { static bool renderCompositionPanel(CompositionPanelState& state, const std::vector& primitives, std::string& outCode, - std::string& outputLog) { + NotificationSystem& notifications) { outCode.clear(); ImGui::InputText("Input Type", state.inputType, sizeof(state.inputType)); ImGui::InputText("Output Type", state.outputType, sizeof(state.outputType)); @@ -48,7 +49,8 @@ static bool renderCompositionPanel(CompositionPanelState& state, if (state.selected >= 0 && state.selected < (int)suggestions.size()) { if (ImGui::Button("Add Step")) { if (state.steps.size() >= 8) { - outputLog += "[compose] Max 8 steps.\n"; + notifications.notify(NotificationLevel::Warning, + "[compose] Max 8 steps."); } else { state.steps.push_back(suggestions[state.selected]); } @@ -87,7 +89,8 @@ static bool renderCompositionPanel(CompositionPanelState& state, ImGui::EndChild(); if (ImGui::Button("Insert Code")) { outCode = code; - outputLog += "[compose] Inserted pipeline.\n"; + notifications.notify(NotificationLevel::Success, + "[compose] Inserted pipeline."); return true; } } diff --git a/editor/src/DependencyPanel.h b/editor/src/DependencyPanel.h index 900bc02..fa8a2b3 100644 --- a/editor/src/DependencyPanel.h +++ b/editor/src/DependencyPanel.h @@ -2,6 +2,7 @@ #include "imgui.h" #include "DependencyParser.h" #include "PackageRegistry.h" +#include "NotificationSystem.h" #include #include #include @@ -132,23 +133,25 @@ static std::string formatRequirementLine(const DependencySpec& dep) { static bool writeRequirementsFile(const std::string& path, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { std::ofstream out(path); if (!out.is_open()) { - logOut += "[deps] Failed to write " + path + "\n"; + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write " + path); return false; } for (const auto& dep : deps) { if (dep.name.empty()) continue; out << formatRequirementLine(dep) << "\n"; } - logOut += "[deps] Wrote " + path + "\n"; + notifications.notify(NotificationLevel::Success, + "[deps] Wrote " + path); return true; } static bool writePackageJson(const std::string& path, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { nlohmann::json j; if (std::filesystem::exists(path)) { std::ifstream in(path); @@ -175,17 +178,19 @@ static bool writePackageJson(const std::string& path, std::ofstream out(path); if (!out.is_open()) { - logOut += "[deps] Failed to write " + path + "\n"; + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write " + path); return false; } out << j.dump(2); - logOut += "[deps] Wrote " + path + "\n"; + notifications.notify(NotificationLevel::Success, + "[deps] Wrote " + path); return true; } static bool writeCargoToml(const std::string& path, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { std::vector prefix; bool foundDeps = false; if (std::filesystem::exists(path)) { @@ -201,7 +206,8 @@ static bool writeCargoToml(const std::string& path, } std::ofstream out(path); if (!out.is_open()) { - logOut += "[deps] Failed to write " + path + "\n"; + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write " + path); return false; } for (const auto& line : prefix) { @@ -214,13 +220,14 @@ static bool writeCargoToml(const std::string& path, std::string version = dep.version.empty() ? "*" : dep.version; out << dep.name << " = \"" << version << "\"\n"; } - logOut += "[deps] Wrote " + path + "\n"; + notifications.notify(NotificationLevel::Success, + "[deps] Wrote " + path); return true; } static bool writeGoMod(const std::string& path, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { std::string moduleLine; std::string goLine; if (std::filesystem::exists(path)) { @@ -236,7 +243,8 @@ static bool writeGoMod(const std::string& path, std::ofstream out(path); if (!out.is_open()) { - logOut += "[deps] Failed to write " + path + "\n"; + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write " + path); return false; } out << moduleLine << "\n" << goLine << "\n\n"; @@ -247,13 +255,14 @@ static bool writeGoMod(const std::string& path, out << " " << dep.name << " " << version << "\n"; } out << ")\n"; - logOut += "[deps] Wrote " + path + "\n"; + notifications.notify(NotificationLevel::Success, + "[deps] Wrote " + path); return true; } static bool writeVcpkgJson(const std::string& path, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { nlohmann::json j; if (std::filesystem::exists(path)) { std::ifstream in(path); @@ -274,39 +283,43 @@ static bool writeVcpkgJson(const std::string& path, j["dependencies"] = depsArr; std::ofstream out(path); if (!out.is_open()) { - logOut += "[deps] Failed to write " + path + "\n"; + notifications.notify(NotificationLevel::Error, + "[deps] Failed to write " + path); return false; } out << j.dump(2); - logOut += "[deps] Wrote " + path + "\n"; + notifications.notify(NotificationLevel::Success, + "[deps] Wrote " + path); return true; } static bool writeDependenciesForSource(const std::string& source, const std::string& workspaceRoot, const std::vector& deps, - std::string& logOut) { + NotificationSystem& notifications) { if (workspaceRoot.empty()) { - logOut += "[deps] No workspace root set.\n"; + notifications.notify(NotificationLevel::Warning, + "[deps] No workspace root set."); return false; } std::string base = sourceBase(source); std::string path = resolveSourcePath(workspaceRoot, base); auto filtered = depsForSource(deps, base); - if (base == "requirements.txt") return writeRequirementsFile(path, filtered, logOut); - if (base == "package.json") return writePackageJson(path, filtered, logOut); - if (base == "Cargo.toml") return writeCargoToml(path, filtered, logOut); - if (base == "go.mod") return writeGoMod(path, filtered, logOut); - if (base == "vcpkg.json") return writeVcpkgJson(path, filtered, logOut); + if (base == "requirements.txt") return writeRequirementsFile(path, filtered, notifications); + if (base == "package.json") return writePackageJson(path, filtered, notifications); + if (base == "Cargo.toml") return writeCargoToml(path, filtered, notifications); + if (base == "go.mod") return writeGoMod(path, filtered, notifications); + if (base == "vcpkg.json") return writeVcpkgJson(path, filtered, notifications); - logOut += "[deps] Writeback not implemented for " + base + "\n"; + notifications.notify(NotificationLevel::Warning, + "[deps] Writeback not implemented for " + base); return false; } static void refreshDependencies(DependencyPanelState& state, const std::string& workspaceRoot, - std::string& logOut) { + NotificationSystem& notifications) { state.deps.clear(); state.sources = discoverDependencyFiles(workspaceRoot); state.selected = -1; @@ -325,24 +338,25 @@ static void refreshDependencies(DependencyPanelState& state, } else { state.sourceSelected = -1; } - logOut += "[deps] Loaded " + std::to_string(state.deps.size()) + " dependencies.\n"; + notifications.notify(NotificationLevel::Info, + "[deps] Loaded " + std::to_string(state.deps.size()) + " dependencies."); } static void renderDependencyPanel(DependencyPanelState& state, const std::string& workspaceRoot, - std::string& logOut) { + NotificationSystem& notifications) { if (workspaceRoot.empty()) { ImGui::TextDisabled("Open a workspace to manage dependencies."); return; } if (state.lastWorkspaceRoot != workspaceRoot || state.deps.empty()) { - refreshDependencies(state, workspaceRoot, logOut); + refreshDependencies(state, workspaceRoot, notifications); } ImGui::InputText("Search", state.searchBuf, sizeof(state.searchBuf)); ImGui::SameLine(); if (ImGui::Button("Refresh")) { - refreshDependencies(state, workspaceRoot, logOut); + refreshDependencies(state, workspaceRoot, notifications); } ImGui::SameLine(); if (ImGui::Button("Add Package")) { @@ -388,8 +402,9 @@ static void renderDependencyPanel(DependencyPanelState& state, } if (ImGui::Button("Search Registry")) { state.lastLookup = PackageRegistry::query(state.addEcosystem, state.addBuf); - logOut += "[deps] Registry: " + state.lastLookup.name + " - " + - state.lastLookup.description + "\n"; + notifications.notify(NotificationLevel::Info, + "[deps] Registry: " + state.lastLookup.name + " - " + + state.lastLookup.description); } if (!state.lastLookup.name.empty()) { ImGui::TextWrapped("Found: %s", state.lastLookup.name.c_str()); @@ -397,7 +412,8 @@ static void renderDependencyPanel(DependencyPanelState& state, ImGui::Separator(); if (ImGui::Button("Add")) { if (state.addBuf[0] == '\0') { - logOut += "[deps] Add aborted: empty package name.\n"; + notifications.notify(NotificationLevel::Warning, + "[deps] Add aborted: empty package name."); } else { DependencySpec dep; dep.name = state.addBuf; @@ -408,8 +424,8 @@ static void renderDependencyPanel(DependencyPanelState& state, dep.source = "requirements.txt"; } state.deps.push_back(dep); - if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { - refreshDependencies(state, workspaceRoot, logOut); + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { + refreshDependencies(state, workspaceRoot, notifications); state.needsIndex = true; } } @@ -450,8 +466,8 @@ static void renderDependencyPanel(DependencyPanelState& state, ImGui::InputText("Version", state.editVersionBuf, sizeof(state.editVersionBuf)); if (ImGui::Button("Apply Version")) { dep.version = state.editVersionBuf; - if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { - refreshDependencies(state, workspaceRoot, logOut); + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { + refreshDependencies(state, workspaceRoot, notifications); state.needsIndex = true; } } @@ -460,8 +476,8 @@ static void renderDependencyPanel(DependencyPanelState& state, std::string src = dep.source; state.deps.erase(state.deps.begin() + state.selected); state.selected = -1; - if (writeDependenciesForSource(src, workspaceRoot, state.deps, logOut)) { - refreshDependencies(state, workspaceRoot, logOut); + if (writeDependenciesForSource(src, workspaceRoot, state.deps, notifications)) { + refreshDependencies(state, workspaceRoot, notifications); state.needsIndex = true; } } @@ -471,12 +487,13 @@ static void renderDependencyPanel(DependencyPanelState& state, auto info = PackageRegistry::query(eco, dep.name); if (!info.versions.empty()) { dep.version = info.versions.back(); - if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { - refreshDependencies(state, workspaceRoot, logOut); + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) { + refreshDependencies(state, workspaceRoot, notifications); state.needsIndex = true; } } else { - logOut += "[deps] No version info for " + dep.name + "\n"; + notifications.notify(NotificationLevel::Warning, + "[deps] No version info for " + dep.name); } } } diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index a0ee81d..e52bdc8 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -49,6 +49,7 @@ #include "HelpPanel.h" #include "Telemetry.h" #include "UpdateChecker.h" +#include "NotificationSystem.h" #include "state/SearchState.h" #include "state/AgentState.h" #include "state/BuildState.h" @@ -172,8 +173,7 @@ struct EditorState { EmacsState emacsState; UIFlags ui; - // Bottom panel + global log - std::string outputLog; + NotificationSystem notifications; HelpPanelState helpPanel; Telemetry telemetry; char outlineFilter[128] = {}; @@ -326,6 +326,26 @@ struct EditorState { return text.substr(start, pos - start); } + void notify(NotificationLevel level, const std::string& message) { + notifications.notify(level, message); + } + + void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) { + notifications.notify(level, message, target); + } + + void navigateToTarget(const NotificationTarget& target) { + if (target.path.empty()) return; + std::string path = target.path; + if (buffers.hasBuffer(path)) switchToBuffer(path); + else doOpen(path); + if (active()) { + int line = std::max(0, target.line); + int col = std::max(0, target.col); + jumpTo(active(), line, col); + } + } + void jumpTo(BufferState* buf, int lineZero, int colZero) { if (!buf) return; int pos = byteOffsetForLineCol(buf->editBuf, lineZero, colZero); @@ -760,7 +780,7 @@ struct EditorState { } void init() { - outputLog = "Whetstone Editor ready.\n"; + notify(NotificationLevel::Info, "Whetstone Editor ready."); workspaceRoot = std::filesystem::current_path().string(); search.projectSearch.setRoot(workspaceRoot); fileTreeDirty = true; @@ -824,7 +844,7 @@ struct EditorState { std::string log; bool ok = emacsState.emacs.startDaemonWithConfig(initPath, log); if (!log.empty()) { - outputLog += "[emacs] " + log + "\n"; + notify(NotificationLevel::Info, "[emacs] " + log); } if (!ok) { EditorDiagnostic d; @@ -936,7 +956,8 @@ struct EditorState { build.lastBuildCommand = cmd.command; int code = build.terminal.runAndCapture(workspaceRoot, cmd.command, build.lastBuildOutput); build.buildErrors = BuildSystem::parseErrors(build.lastBuildOutput); - outputLog += "[build] " + cmd.label + " => exit " + std::to_string(code) + "\n"; + notify(NotificationLevel::Info, + "[build] " + cmd.label + " => exit " + std::to_string(code)); return code; } @@ -953,13 +974,15 @@ struct EditorState { library.libraryIndex.completionsByLibrary.clear(); library.libraryIndexRequests.clear(); if (!isStructured() || !activeAST()) { - outputLog += "[deps] Library index skipped: no structured AST.\n"; + notify(NotificationLevel::Warning, + "[deps] Library index skipped: no structured AST."); return; } if (!lsp || !lspTransport || !lspTransport->isOpen()) { applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot); rebuildExternalModulesFromIndex(); - outputLog += "[deps] Library index uses cached symbols (LSP offline).\n"; + notify(NotificationLevel::Warning, + "[deps] Library index uses cached symbols (LSP offline)."); return; } @@ -980,8 +1003,9 @@ struct EditorState { } applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot); rebuildExternalModulesFromIndex(); - outputLog += "[deps] Library index requests queued: " + - std::to_string(library.libraryIndexRequests.size()) + "\n"; + notify(NotificationLevel::Info, + "[deps] Library index requests queued: " + + std::to_string(library.libraryIndexRequests.size())); } void processLibraryIndexResponses() { @@ -1015,22 +1039,23 @@ struct EditorState { if (updated) { applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot); rebuildExternalModulesFromIndex(); - outputLog += "[deps] Library index updated.\n"; + notify(NotificationLevel::Info, "[deps] Library index updated."); } } void updateEmacsFunctionIndex() { if (!active() || active()->language != "elisp") return; std::string error; - auto packages = queryEmacsPackageList(emacsState.emacs, false, outputLog, error); + auto packages = queryEmacsPackageList(emacsState.emacs, false, notifications, error); if (!error.empty()) return; - refreshEmacsFunctionIndex(emacsState.emacsFunctionIndex, emacsState.emacs, packages, outputLog); + refreshEmacsFunctionIndex(emacsState.emacsFunctionIndex, emacsState.emacs, packages, notifications); addEmacsSymbolsToLibraryIndex(library.libraryIndex, emacsState.emacsFunctionIndex); emacsState.emacsFunctionIndexDirty = false; rebuildExternalModulesFromIndex(); - outputLog += "[emacs] Indexed functions for " + - std::to_string(emacsState.emacsFunctionIndex.functionsByPackage.size()) + - " packages.\n"; + notify(NotificationLevel::Info, + "[emacs] Indexed functions for " + + std::to_string(emacsState.emacsFunctionIndex.functionsByPackage.size()) + + " packages."); } void rebuildExternalModulesFromIndex() { @@ -1544,7 +1569,8 @@ struct EditorState { {"Open Project", {"*.whetstone"}, std::string()}); if (!path.empty()) { if (!loadProject(path)) { - outputLog += "Failed to open project: " + path + "\n"; + notify(NotificationLevel::Error, + "Failed to open project: " + path); } } }); @@ -1555,7 +1581,8 @@ struct EditorState { {"Save Project", {"*.whetstone"}, std::string()}); if (!path.empty()) { if (!saveProject(path)) { - outputLog += "Failed to save project: " + path + "\n"; + notify(NotificationLevel::Error, + "Failed to save project: " + path); } } }); @@ -1669,54 +1696,58 @@ struct EditorState { bool handleEmacsKeyChord(const std::string& chord) { if (ui.layoutPreset != LayoutPreset::Emacs) return false; - return emacsHandleKeySequence(emacsState.emacsKeys, emacsState.emacs, chord, outputLog); + return emacsHandleKeySequence(emacsState.emacsKeys, emacsState.emacs, chord, notifications); } void refreshEmacsModeLine(double nowSeconds) { if (ui.layoutPreset != LayoutPreset::Emacs) return; - updateEmacsModeLine(emacsState.emacsKeys, emacsState.emacs, nowSeconds, outputLog); + updateEmacsModeLine(emacsState.emacsKeys, emacsState.emacs, nowSeconds, notifications); } void pullFromEmacs() { if (!active()) return; if (active()->path.rfind("(untitled", 0) == 0) { - outputLog += "[emacs] Save file before syncing.\n"; + notify(NotificationLevel::Warning, "[emacs] Save file before syncing."); return; } std::string text = emacsState.emacs.getBufferText(active()->path); if (!emacsState.emacs.getLastError().empty() && text == "error") { - outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n"; + notify(NotificationLevel::Error, + "[emacs] " + emacsState.emacs.getLastError()); return; } active()->editBuf = text; onTextChanged(); - outputLog += "[emacs] Pulled buffer from Emacs.\n"; + notify(NotificationLevel::Success, "[emacs] Pulled buffer from Emacs."); } void pushToEmacs() { if (!active()) return; if (active()->path.rfind("(untitled", 0) == 0) { - outputLog += "[emacs] Save file before syncing.\n"; + notify(NotificationLevel::Warning, "[emacs] Save file before syncing."); return; } if (!emacsState.emacs.setBufferText(active()->path, active()->editBuf)) { - outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n"; + notify(NotificationLevel::Error, + "[emacs] " + emacsState.emacs.getLastError()); return; } - outputLog += "[emacs] Pushed buffer to Emacs.\n"; + notify(NotificationLevel::Success, "[emacs] Pushed buffer to Emacs."); } void openInEmacsFrame() { if (!active()) return; if (active()->path.rfind("(untitled", 0) == 0) { - outputLog += "[emacs] Save file before opening in Emacs.\n"; + notify(NotificationLevel::Warning, + "[emacs] Save file before opening in Emacs."); return; } if (!emacsState.emacs.openFileInEmacsFrame(active()->path)) { - outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n"; + notify(NotificationLevel::Error, + "[emacs] " + emacsState.emacs.getLastError()); return; } - outputLog += "[emacs] Opened in Emacs frame.\n"; + notify(NotificationLevel::Success, "[emacs] Opened in Emacs frame."); } std::string buildRunCommand(const std::string& path, @@ -1840,11 +1871,18 @@ struct EditorState { } active()->cursorLine = line; active()->cursorCol = col; - outputLog += "Found \"" + std::string(search.findBuf) + "\" at line " + - std::to_string(line) + ", col " + std::to_string(col) + "\n"; + NotificationTarget target; + target.path = active()->path; + target.line = std::max(0, line - 1); + target.col = std::max(0, col - 1); + notify(NotificationLevel::Info, + "Found \"" + std::string(search.findBuf) + "\" at line " + + std::to_string(line) + ", col " + std::to_string(col), + target); } else { search.lastFindPos = 0; - outputLog += "\"" + std::string(search.findBuf) + "\" not found.\n"; + notify(NotificationLevel::Warning, + "\"" + std::string(search.findBuf) + "\" not found."); } } @@ -1863,7 +1901,8 @@ struct EditorState { active()->generatedHighlightsDirty = true; active()->modified = true; } - outputLog += "Replaced " + std::to_string(count) + " occurrence(s).\n"; + notify(NotificationLevel::Info, + "Replaced " + std::to_string(count) + " occurrence(s)."); } void doSave() { @@ -1874,10 +1913,12 @@ struct EditorState { out << active()->editBuf; out.close(); active()->modified = false; - outputLog += "Saved: " + active()->path + "\n"; + notify(NotificationLevel::Success, + "Saved: " + active()->path); if (lsp) lsp->didSave(toFileUri(active()->path), active()->editBuf); } else { - outputLog += "Error saving: " + active()->path + "\n"; + notify(NotificationLevel::Error, + "Error saving: " + active()->path); } } @@ -1912,9 +1953,9 @@ struct EditorState { createBuffer(path, content, language, modeOverride); welcome.addRecentFile(path, language, bufferModeToString(modeOverride)); saveRecentFiles(); - outputLog += "Opened: " + path + "\n"; + notify(NotificationLevel::Success, "Opened: " + path); } else { - outputLog += "Error opening: " + path + "\n"; + notify(NotificationLevel::Error, "Error opening: " + path); } } @@ -1936,7 +1977,7 @@ struct EditorState { buf->highlightsDirty = true; buf->generatedHighlightsDirty = true; buf->modified = false; - outputLog += "Reloaded: " + path + "\n"; + notify(NotificationLevel::Info, "Reloaded: " + path); } void handleFileChanges() { @@ -1945,7 +1986,8 @@ struct EditorState { auto it = bufferStates.find(path); if (it == bufferStates.end()) continue; if (it->second->modified) { - outputLog += "File changed on disk (dirty): " + path + "\n"; + notify(NotificationLevel::Warning, + "File changed on disk (dirty): " + path); } else { reloadBuffer(path); } @@ -1979,14 +2021,16 @@ struct EditorState { if (!active()) return; Module* ast = activeAST(); if (!ast) { - outputLog += "Project to " + targetLanguage + ": no AST available.\n"; + notify(NotificationLevel::Error, + "Project to " + targetLanguage + ": no AST available."); return; } CrossLanguageProjector projector; auto projected = projector.project(ast, targetLanguage); if (!projected) { - outputLog += "Project to " + targetLanguage + ": failed to project AST.\n"; + notify(NotificationLevel::Error, + "Project to " + targetLanguage + ": failed to project AST."); return; } @@ -2018,10 +2062,11 @@ struct EditorState { active()->modified = false; } - outputLog += "Projected to " + targetLanguage + " in " + projName + - " (annotations " + std::to_string(srcAnnoCount) + " -> " + - std::to_string(projAnnoCount) + ", types preserved: " + - (preserved ? "yes" : "no") + ").\n"; + notify(NotificationLevel::Success, + "Projected to " + targetLanguage + " in " + projName + + " (annotations " + std::to_string(srcAnnoCount) + " -> " + + std::to_string(projAnnoCount) + ", types preserved: " + + (preserved ? "yes" : "no") + ")."); } void refreshActiveTextFromAST() { diff --git a/editor/src/EmacsFunctionDiscovery.h b/editor/src/EmacsFunctionDiscovery.h index 1854252..968a100 100644 --- a/editor/src/EmacsFunctionDiscovery.h +++ b/editor/src/EmacsFunctionDiscovery.h @@ -2,6 +2,7 @@ #include "EmacsIntegration.h" #include "EmacsPackageBrowser.h" #include "LibraryIndexer.h" +#include "NotificationSystem.h" #include "ast/Module.h" #include "ast/ExternalModule.h" #include "ast/TypeSignature.h" @@ -35,11 +36,12 @@ static std::vector parseLines(const std::string& text) { static std::vector queryEmacsFunctions(EmacsConnection& emacs, const std::string& prefix, - std::string& logOut) { + NotificationSystem& notifications) { std::string cmd = ElispCommandBuilder::aproposFunctions(prefix); std::string resp = emacs.sendCommand(cmd); if (resp.empty() && !emacs.getLastError().empty()) { - logOut += "[emacs] " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + emacs.getLastError()); return {}; } return parseLines(resp); @@ -47,13 +49,14 @@ static std::vector queryEmacsFunctions(EmacsConnection& emacs, static EmacsFunctionDoc queryEmacsFunctionDoc(EmacsConnection& emacs, const std::string& name, - std::string& logOut) { + NotificationSystem& notifications) { EmacsFunctionDoc info; info.name = name; std::string cmd = ElispCommandBuilder::describeFunction(name); std::string resp = emacs.sendCommand(cmd); if (resp.empty() && !emacs.getLastError().empty()) { - logOut += "[emacs] " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + emacs.getLastError()); return info; } auto pos = resp.find('\n'); @@ -81,20 +84,20 @@ static std::vector buildPackageFunctionPrefixes(const std::string& static void refreshEmacsFunctionIndex(EmacsFunctionIndex& index, EmacsConnection& emacs, const std::vector& packages, - std::string& logOut) { + NotificationSystem& notifications) { index.functionsByPackage.clear(); for (const auto& pkg : packages) { if (pkg.status != "loaded") continue; std::vector functions; for (const auto& prefix : buildPackageFunctionPrefixes(pkg.name)) { - functions = queryEmacsFunctions(emacs, "^" + prefix, logOut); + functions = queryEmacsFunctions(emacs, "^" + prefix, notifications); if (!functions.empty()) break; } if (functions.empty()) continue; std::vector docs; docs.reserve(functions.size()); for (const auto& fn : functions) { - docs.push_back(queryEmacsFunctionDoc(emacs, fn, logOut)); + docs.push_back(queryEmacsFunctionDoc(emacs, fn, notifications)); } index.functionsByPackage[pkg.name] = std::move(docs); } diff --git a/editor/src/EmacsKeybinding.h b/editor/src/EmacsKeybinding.h index ab9c74a..9d7ca00 100644 --- a/editor/src/EmacsKeybinding.h +++ b/editor/src/EmacsKeybinding.h @@ -1,5 +1,6 @@ #pragma once #include "EmacsIntegration.h" +#include "NotificationSystem.h" #include #include #include @@ -29,7 +30,7 @@ static inline bool emacsIsPrefixKey(const std::string& chord) { static inline bool emacsHandleKeySequence(EmacsKeybindingState& state, EmacsConnection& emacs, const std::string& chord, - std::string& logOut) { + NotificationSystem& notifications) { if (chord.empty()) return false; if (chord == "M-x") { state.minibufferActive = true; @@ -49,22 +50,25 @@ static inline bool emacsHandleKeySequence(EmacsKeybindingState& state, std::string cmd = emacs.sendCommand(ElispCommandBuilder::keyBinding(sequence)); if (cmd.empty()) { - logOut += "[emacs] Unbound: " + sequence + "\n"; + notifications.notify(NotificationLevel::Warning, + "[emacs] Unbound: " + sequence); return true; } state.lastCommand = cmd; std::string result = emacs.sendCommand(ElispCommandBuilder::callInteractive(cmd)); if (!emacs.getLastError().empty() && result == "error") { - logOut += "[emacs] " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + emacs.getLastError()); } else { - logOut += "[emacs] " + cmd + "\n"; + notifications.notify(NotificationLevel::Info, + "[emacs] " + cmd); } return true; } static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state, EmacsConnection& emacs, - std::string& logOut) { + NotificationSystem& notifications) { std::string cmd = emacsTrim(state.minibufferBuf); if (cmd.empty()) { state.minibufferActive = false; @@ -72,9 +76,11 @@ static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state, } std::string result = emacs.sendCommand(ElispCommandBuilder::executeExtendedCommand(cmd)); if (!emacs.getLastError().empty() && result == "error") { - logOut += "[emacs] " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + emacs.getLastError()); } else { - logOut += "[emacs] M-x " + cmd + "\n"; + notifications.notify(NotificationLevel::Info, + "[emacs] M-x " + cmd); } state.minibufferActive = false; state.minibufferBuf[0] = '\0'; @@ -84,11 +90,12 @@ static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state, static inline void updateEmacsModeLine(EmacsKeybindingState& state, EmacsConnection& emacs, double nowSeconds, - std::string& logOut) { + NotificationSystem& notifications) { if (nowSeconds - state.lastModeQuery < 1.0) return; std::string mode = emacs.sendCommand(ElispCommandBuilder::modeLine()); if (!emacs.getLastError().empty() && mode == "error") { - logOut += "[emacs] " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + emacs.getLastError()); return; } if (!mode.empty()) state.modeLine = mode; diff --git a/editor/src/EmacsPackageBrowser.h b/editor/src/EmacsPackageBrowser.h index 0867dc3..1ef23e7 100644 --- a/editor/src/EmacsPackageBrowser.h +++ b/editor/src/EmacsPackageBrowser.h @@ -1,6 +1,7 @@ #pragma once #include "imgui.h" #include "EmacsIntegration.h" +#include "NotificationSystem.h" #include "StringUtils.h" #include #include @@ -118,13 +119,14 @@ static std::vector mergePackageLists(const std::vector queryEmacsPackageList(EmacsConnection& emacs, bool includeAvailable, - std::string& logOut, + NotificationSystem& notifications, std::string& outError) { outError.clear(); std::string loadedText = emacs.sendCommand(buildPackageListCommand("package-alist")); if (loadedText.empty() && !emacs.getLastError().empty()) { outError = emacs.getLastError(); - logOut += "[emacs] " + outError + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + outError); } auto loaded = parsePackageListText(loadedText); std::vector available; @@ -132,7 +134,8 @@ static std::vector queryEmacsPackageList(EmacsConnection& ema std::string availText = emacs.sendCommand(buildPackageListCommand("package-archive-contents")); if (availText.empty() && !emacs.getLastError().empty()) { outError = emacs.getLastError(); - logOut += "[emacs] " + outError + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] " + outError); } available = parsePackageListText(availText); } @@ -141,23 +144,26 @@ static std::vector queryEmacsPackageList(EmacsConnection& ema static void refreshEmacsPackages(EmacsPackageBrowserState& state, EmacsConnection& emacs, - std::string& logOut) { + NotificationSystem& notifications) { state.lastError.clear(); - state.packages = queryEmacsPackageList(emacs, state.showAvailable, logOut, state.lastError); + state.packages = queryEmacsPackageList(emacs, state.showAvailable, notifications, state.lastError); state.needsRefresh = false; } static bool loadEmacsPackage(EmacsConnection& emacs, const std::string& packageName, - std::string& logOut) { + NotificationSystem& notifications) { if (packageName.empty()) return false; std::string cmd = "(require '" + packageName + ")"; std::string result = emacs.sendCommand(cmd); if (!emacs.getLastError().empty() && result == "error") { - logOut += "[emacs] Failed to load " + packageName + ": " + emacs.getLastError() + "\n"; + notifications.notify(NotificationLevel::Error, + "[emacs] Failed to load " + packageName + ": " + + emacs.getLastError()); return false; } - logOut += "[emacs] Loaded package: " + packageName + "\n"; + notifications.notify(NotificationLevel::Success, + "[emacs] Loaded package: " + packageName); return true; } @@ -170,7 +176,7 @@ static bool emacsPackageMatchesFilter(const EmacsPackageEntry& entry, const std: static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state, EmacsConnection& emacs, - std::string& logOut) { + NotificationSystem& notifications) { bool updated = false; if (ImGui::Button("Refresh")) { state.needsRefresh = true; @@ -183,7 +189,7 @@ static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state, ImGui::InputText("Filter", state.filterBuf, sizeof(state.filterBuf)); if (state.needsRefresh) { - refreshEmacsPackages(state, emacs, logOut); + refreshEmacsPackages(state, emacs, notifications); updated = true; } @@ -236,11 +242,13 @@ static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state, } if (ImGui::Button("Load")) { if (loadTarget.empty()) { - logOut += "[emacs] No package selected.\n"; + notifications.notify(NotificationLevel::Warning, + "[emacs] No package selected."); } else { - bool ok = loadEmacsPackage(emacs, loadTarget, logOut); + bool ok = loadEmacsPackage(emacs, loadTarget, notifications); if (!ok) { - logOut += "[emacs] Package not installed: " + loadTarget + "\n"; + notifications.notify(NotificationLevel::Warning, + "[emacs] Package not installed: " + loadTarget); } state.needsRefresh = true; updated = true; diff --git a/editor/src/LibraryBrowserPanel.h b/editor/src/LibraryBrowserPanel.h index 2706f18..c42df4b 100644 --- a/editor/src/LibraryBrowserPanel.h +++ b/editor/src/LibraryBrowserPanel.h @@ -2,6 +2,7 @@ #include "imgui.h" #include "LibraryIndexer.h" #include "ast/ExternalModule.h" +#include "NotificationSystem.h" #include #include #include @@ -58,7 +59,7 @@ static bool renderLibraryBrowser(LibraryBrowserState& state, const LibraryIndexData& index, std::string& outInsert, std::string& outLibrary, - std::string& outputLog) { + NotificationSystem& notifications) { outInsert.clear(); outLibrary.clear(); if (!ast) { @@ -112,7 +113,8 @@ static bool renderLibraryBrowser(LibraryBrowserState& state, if (ImGui::Button("Insert")) { outInsert = usage; outLibrary = state.selectedLibrary; - outputLog += "[libs] Inserted " + usage + "\n"; + notifications.notify(NotificationLevel::Success, + "[libs] Inserted " + usage); inserted = true; } ImGui::SameLine(); diff --git a/editor/src/NotificationSystem.h b/editor/src/NotificationSystem.h new file mode 100644 index 0000000..0772112 --- /dev/null +++ b/editor/src/NotificationSystem.h @@ -0,0 +1,230 @@ +#pragma once + +#include "imgui.h" + +#include +#include +#include + +enum class NotificationLevel { + Info, + Success, + Warning, + Error +}; + +struct NotificationTarget { + std::string path; + int line = -1; // zero-based + int col = -1; // zero-based +}; + +struct Notification { + int id = 0; + NotificationLevel level = NotificationLevel::Info; + std::string message; + double timestamp = 0.0; + float durationSeconds = 4.0f; + bool dismissed = false; + bool read = false; + bool hasTarget = false; + NotificationTarget target; +}; + +class NotificationSystem { +public: + float toastDurationSeconds = 4.0f; + bool showHistory = false; + + void notify(NotificationLevel level, const std::string& message) { + notifyAt(level, message, ImGui::GetTime(), nullptr); + } + + void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) { + notifyAt(level, message, ImGui::GetTime(), &target); + } + + void notifyAt(NotificationLevel level, const std::string& message, double nowSeconds) { + notifyAt(level, message, nowSeconds, nullptr); + } + + void notifyAt(NotificationLevel level, + const std::string& message, + double nowSeconds, + const NotificationTarget* target) { + Notification n; + n.id = nextId++; + n.level = level; + n.message = message; + n.timestamp = nowSeconds; + n.durationSeconds = toastDurationSeconds; + if (target) { + n.hasTarget = true; + n.target = *target; + } + history.push_back(std::move(n)); + } + + void dismiss(int id) { + for (auto& n : history) { + if (n.id == id) { + n.dismissed = true; + n.read = true; + return; + } + } + } + + void markAllRead() { + for (auto& n : history) { + n.read = true; + } + } + + int unreadCount() const { + int count = 0; + for (const auto& n : history) { + if (!n.read) ++count; + } + return count; + } + + const std::vector& getHistory() const { + return history; + } + + void renderToasts(const std::function& onNavigate = {}) { + const double now = ImGui::GetTime(); + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + const ImVec2 base(viewport->WorkPos.x + viewport->WorkSize.x, + viewport->WorkPos.y + viewport->WorkSize.y); + const float padding = 12.0f; + const float toastWidth = 360.0f; + float y = base.y - padding; + + for (int i = (int)history.size() - 1; i >= 0; --i) { + auto& n = history[i]; + if (n.dismissed) continue; + if ((now - n.timestamp) > n.durationSeconds) continue; + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoSavedSettings; + + ImGui::SetNextWindowPos(ImVec2(base.x - padding, y), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(toastWidth, 0.0f), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 10)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.92f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui::Begin(("##toast_" + std::to_string(n.id)).c_str(), nullptr, flags); + + ImVec4 color = levelColor(n.level); + ImGui::TextColored(color, "%s", levelLabel(n.level)); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextWrapped("%s", n.message.c_str()); + if (n.hasTarget) { + std::string loc = n.target.path; + if (n.target.line >= 0) { + loc += ":" + std::to_string(n.target.line + 1); + if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1); + } + ImGui::TextDisabled("%s", loc.c_str()); + } + + bool clicked = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && + ImGui::IsMouseClicked(0); + ImVec2 size = ImGui::GetWindowSize(); + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + + if (clicked) { + n.read = true; + n.dismissed = true; + if (n.hasTarget && onNavigate) { + onNavigate(n); + } + } + + y -= size.y + 8.0f; + } + } + + void renderHistoryWindow(const std::function& onNavigate = {}) { + if (!showHistory) { + historyWasOpen = false; + return; + } + + if (!historyWasOpen) { + markAllRead(); + historyWasOpen = true; + } + + ImGui::SetNextWindowSize(ImVec2(520, 360), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Notifications", &showHistory)) { + ImGui::End(); + return; + } + + if (history.empty()) { + ImGui::TextDisabled("(no notifications yet)"); + ImGui::End(); + return; + } + + ImGui::BeginChild("##notificationHistory", ImVec2(0, 0), false); + for (const auto& n : history) { + ImGui::PushID(n.id); + ImVec4 color = levelColor(n.level); + ImGui::TextColored(color, "%s", levelLabel(n.level)); + ImGui::SameLine(); + if (ImGui::Selectable(n.message.c_str(), false)) { + if (n.hasTarget && onNavigate) { + onNavigate(n); + } + } + if (n.hasTarget) { + std::string loc = n.target.path; + if (n.target.line >= 0) { + loc += ":" + std::to_string(n.target.line + 1); + if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1); + } + ImGui::TextDisabled("%s", loc.c_str()); + } + ImGui::Separator(); + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::End(); + } + +private: + int nextId = 1; + bool historyWasOpen = false; + std::vector history; + + static const char* levelLabel(NotificationLevel level) { + switch (level) { + case NotificationLevel::Success: return "Success"; + case NotificationLevel::Warning: return "Warning"; + case NotificationLevel::Error: return "Error"; + case NotificationLevel::Info: + default: + return "Info"; + } + } + + static ImVec4 levelColor(NotificationLevel level) { + switch (level) { + case NotificationLevel::Success: return ImVec4(0.25f, 0.78f, 0.35f, 1.0f); + case NotificationLevel::Warning: return ImVec4(0.95f, 0.72f, 0.28f, 1.0f); + case NotificationLevel::Error: return ImVec4(0.90f, 0.35f, 0.35f, 1.0f); + case NotificationLevel::Info: + default: + return ImVec4(0.40f, 0.70f, 0.95f, 1.0f); + } + } +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index dc0ab62..b78b031 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -298,6 +298,12 @@ int main(int, char**) { renderBottomPanel(state); renderMemoryStrategiesPanel(state); renderStatusBar(state); + state.notifications.renderHistoryWindow([&](const Notification& note) { + if (note.hasTarget) state.navigateToTarget(note.target); + }); + state.notifications.renderToasts([&](const Notification& note) { + if (note.hasTarget) state.navigateToTarget(note.target); + }); // Check for exit request from menu if (state.exitRequested) done = true; diff --git a/editor/src/panels/BottomPanel.h b/editor/src/panels/BottomPanel.h index 99be410..afd30ac 100644 --- a/editor/src/panels/BottomPanel.h +++ b/editor/src/panels/BottomPanel.h @@ -13,8 +13,30 @@ static void renderBottomPanel(EditorState& state) { if (ImGui::BeginTabItem("Output")) { ImGui::PushFont(state.monoFont); ImGui::BeginChild("##outputScroll", ImVec2(0, 0), false); - ImGui::TextUnformatted(state.outputLog.c_str()); - // Auto-scroll to bottom + for (const auto& note : state.notifications.getHistory()) { + const char* label = "Info"; + ImVec4 color = ImVec4(0.40f, 0.70f, 0.95f, 1.0f); + switch (note.level) { + case NotificationLevel::Success: + label = "Success"; + color = ImVec4(0.25f, 0.78f, 0.35f, 1.0f); + break; + case NotificationLevel::Warning: + label = "Warning"; + color = ImVec4(0.95f, 0.72f, 0.28f, 1.0f); + break; + case NotificationLevel::Error: + label = "Error"; + color = ImVec4(0.90f, 0.35f, 0.35f, 1.0f); + break; + case NotificationLevel::Info: + default: + break; + } + ImGui::TextColored(color, "[%s]", label); + ImGui::SameLine(); + ImGui::TextWrapped("%s", note.message.c_str()); + } if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 20) ImGui::SetScrollHereY(1.0f); ImGui::EndChild(); @@ -86,7 +108,7 @@ static void renderBottomPanel(EditorState& state) { ImGui::PushFont(state.monoFont); renderAgentMarketplace(state.agent.marketplace, state.agent.registry, - state.outputLog, + state.notifications, state.workspaceRoot); ImGui::PopFont(); ImGui::EndTabItem(); @@ -261,10 +283,10 @@ static void renderBottomPanel(EditorState& state) { } if (!warning.empty()) { summary += " — warning: " + warning; - state.outputLog += warning + "\n"; + state.notify(NotificationLevel::Warning, warning); } state.optimizeFoldSummary = summary; - state.outputLog += summary + "\n"; + state.notify(NotificationLevel::Info, summary); if (nodes > 0) state.applyOrchestratorToActive(); std::string afterText = state.active()->editBuf; state.openDiff(beforeText, afterText, false, 1, {tid}); @@ -308,7 +330,7 @@ static void renderBottomPanel(EditorState& state) { summary += "no changes"; } state.optimizeDeadCodeSummary = summary; - state.outputLog += summary + "\n"; + state.notify(NotificationLevel::Info, summary); if (nodes > 0) state.applyOrchestratorToActive(); std::string afterText = state.active()->editBuf; state.openDiff(beforeText, afterText, false, 2, {tid}); @@ -354,10 +376,10 @@ static void renderBottomPanel(EditorState& state) { } if (!warning.empty()) { summary += " — warning: " + warning; - state.outputLog += warning + "\n"; + state.notify(NotificationLevel::Warning, warning); } state.optimizeApplySummary = summary; - state.outputLog += summary + "\n"; + state.notify(NotificationLevel::Info, summary); if (totalNodes > 0) state.applyOrchestratorToActive(); std::string afterText = state.active()->editBuf; state.openDiff(beforeText, afterText, false, 3, {tidFold, tidDce}); @@ -460,10 +482,11 @@ static void renderBottomPanel(EditorState& state) { batch.setRoot(ast); auto res = batch.applySequence(state.diff.batchMutations); if (!res.success) { - state.outputLog += res.error + "\n"; + state.notify(NotificationLevel::Error, res.error); } else { - state.outputLog += "Refactor applied (" + - std::to_string(res.appliedCount) + " mutations).\n"; + state.notify(NotificationLevel::Success, + "Refactor applied (" + + std::to_string(res.appliedCount) + " mutations)."); state.applyOrchestratorToActive(); } } else { diff --git a/editor/src/panels/DialogPanels.h b/editor/src/panels/DialogPanels.h index aaf3859..993d080 100644 --- a/editor/src/panels/DialogPanels.h +++ b/editor/src/panels/DialogPanels.h @@ -155,8 +155,9 @@ static void renderRefactorPopup(EditorState& state) { if (!res.success) { state.refactorError = res.error; } else { - state.outputLog += "Refactor applied (" + - std::to_string(res.appliedCount) + " mutations).\n"; + state.notify(NotificationLevel::Success, + "Refactor applied (" + + std::to_string(res.appliedCount) + " mutations)."); state.applyOrchestratorToActive(); state.showRefactorPopup = false; } diff --git a/editor/src/panels/EditorPanel.h b/editor/src/panels/EditorPanel.h index eadeca1..9d527f2 100644 --- a/editor/src/panels/EditorPanel.h +++ b/editor/src/panels/EditorPanel.h @@ -452,7 +452,7 @@ static void renderEditorPanel(EditorState& state) { } state.mutator.setRoot(ast); auto res2 = state.mutator.insertNode(target->id, "annotations", anno); - if (!res2.error.empty()) state.outputLog += res2.error + "\n"; + if (!res2.error.empty()) state.notify(NotificationLevel::Error, res2.error); state.applyOrchestratorToActive(); } } @@ -555,7 +555,7 @@ static void renderEditorPanel(EditorState& state) { target->spanEndLine, target->spanEndCol); state.mutator.setRoot(ast); auto res = state.mutator.insertNode(target->id, "annotations", anno); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -566,7 +566,7 @@ static void renderEditorPanel(EditorState& state) { target->spanEndLine, target->spanEndCol); state.mutator.setRoot(ast); auto res = state.mutator.insertNode(target->id, "annotations", anno); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -577,7 +577,7 @@ static void renderEditorPanel(EditorState& state) { target->spanEndLine, target->spanEndCol); state.mutator.setRoot(ast); auto res = state.mutator.insertNode(target->id, "annotations", anno); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -588,7 +588,7 @@ static void renderEditorPanel(EditorState& state) { target->spanEndLine, target->spanEndCol); state.mutator.setRoot(ast); auto res = state.mutator.insertNode(target->id, "annotations", anno); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -599,7 +599,7 @@ static void renderEditorPanel(EditorState& state) { target->spanEndLine, target->spanEndCol); state.mutator.setRoot(ast); auto res = state.mutator.insertNode(target->id, "annotations", anno); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -615,35 +615,35 @@ static void renderEditorPanel(EditorState& state) { if (ImGui::MenuItem("Tracing")) { state.mutator.setRoot(ast); auto res = state.mutator.setProperty(anno->id, "strategy", "Tracing"); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } else if (anno->conceptType == "OwnerAnnotation") { if (ImGui::MenuItem("Single")) { state.mutator.setRoot(ast); auto res = state.mutator.setProperty(anno->id, "strategy", "Single"); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } else if (anno->conceptType == "DeallocateAnnotation") { if (ImGui::MenuItem("Explicit")) { state.mutator.setRoot(ast); auto res = state.mutator.setProperty(anno->id, "strategy", "Explicit"); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } else if (anno->conceptType == "LifetimeAnnotation") { if (ImGui::MenuItem("RAII")) { state.mutator.setRoot(ast); auto res = state.mutator.setProperty(anno->id, "strategy", "RAII"); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } else if (anno->conceptType == "AllocateAnnotation") { if (ImGui::MenuItem("Static")) { state.mutator.setRoot(ast); auto res = state.mutator.setProperty(anno->id, "strategy", "Static"); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -659,7 +659,7 @@ static void renderEditorPanel(EditorState& state) { if (ImGui::MenuItem(label.c_str())) { state.mutator.setRoot(ast); auto res = state.mutator.deleteNode(anno->id); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } @@ -682,7 +682,7 @@ static void renderEditorPanel(EditorState& state) { if (ImGui::MenuItem("Remove child annotation")) { state.mutator.setRoot(ast); auto res = state.mutator.deleteNode(lineConflict.childAnnoId); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } if (ImGui::MenuItem("Match parent strategy")) { @@ -690,7 +690,7 @@ static void renderEditorPanel(EditorState& state) { auto res = state.mutator.setProperty(lineConflict.childAnnoId, "strategy", lineConflict.parentStrategy); - if (!res.error.empty()) state.outputLog += res.error + "\n"; + if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error); if (res.error.empty()) mutated = true; } } diff --git a/editor/src/panels/MenuBarPanel.h b/editor/src/panels/MenuBarPanel.h index 06c5287..9710734 100644 --- a/editor/src/panels/MenuBarPanel.h +++ b/editor/src/panels/MenuBarPanel.h @@ -24,7 +24,8 @@ static void renderMenuBar(EditorState& state) { auto path = FileDialog::openFile({"Open Project", {"*.whetstone"}, state.lastDialogPath}); if (!path.empty()) { if (!state.loadProject(path)) { - state.outputLog += "Failed to open project: " + path + "\n"; + state.notify(NotificationLevel::Error, + "Failed to open project: " + path); } else { state.lastDialogPath = path; } @@ -59,7 +60,8 @@ static void renderMenuBar(EditorState& state) { auto path = FileDialog::saveFile({"Save Project", {"*.whetstone"}, state.lastDialogPath}); if (!path.empty()) { if (!state.saveProject(path)) { - state.outputLog += "Failed to save project: " + path + "\n"; + state.notify(NotificationLevel::Error, + "Failed to save project: " + path); } else { state.lastDialogPath = path; } diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index 1257483..9fb4a64 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -80,9 +80,11 @@ static void renderSettingsPanel(EditorState& state) { UpdateChecker checker; auto info = checker.check(state.settings.getUpdateUrl()); if (info.available) { - state.outputLog += "[update] Available: " + info.version + " (" + info.url + ")\n"; + state.notify(NotificationLevel::Info, + "[update] Available: " + info.version + " (" + info.url + ")"); } else { - state.outputLog += "[update] No update info (offline/stub).\n"; + state.notify(NotificationLevel::Info, + "[update] No update info (offline/stub)."); } } diff --git a/editor/src/panels/SidePanels.h b/editor/src/panels/SidePanels.h index 90bb9b2..c8034ed 100644 --- a/editor/src/panels/SidePanels.h +++ b/editor/src/panels/SidePanels.h @@ -80,7 +80,7 @@ static void renderDependenciesPanel(EditorState& state) { if (!state.library.showDependencyPanel) return; ImGui::Begin("Dependencies", &state.library.showDependencyPanel); ImGui::PushFont(state.uiFont); - renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, state.outputLog); + renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, state.notifications); ImGui::PopFont(); ImGui::End(); if (state.library.dependencyPanel.needsIndex) { @@ -100,7 +100,7 @@ static void renderLibrariesPanel(EditorState& state) { state.library.libraryIndex, insertText, insertLibrary, - state.outputLog)) { + state.notifications)) { state.ensureImportForSymbol(insertLibrary, insertText); state.insertTextAtCursor(insertText); } @@ -123,7 +123,7 @@ static void renderCompositionPanel(EditorState& state) { auto funcs = state.library.primitives.getAvailableFunctions(nodeId); primitives.insert(primitives.end(), funcs.begin(), funcs.end()); std::string code; - if (renderCompositionPanel(state.library.compositionPanel, primitives, code, state.outputLog)) { + if (renderCompositionPanel(state.library.compositionPanel, primitives, code, state.notifications)) { state.insertTextAtCursor(code); } ImGui::PopFont(); @@ -134,7 +134,9 @@ static void renderEmacsPackagesPanel(EditorState& state) { if (!state.emacsState.showEmacsPackagesPanel) return; ImGui::Begin("Emacs Packages", &state.emacsState.showEmacsPackagesPanel); ImGui::PushFont(state.uiFont); - if (renderEmacsPackageBrowser(state.emacsState.emacsPackages, state.emacsState.emacs, state.outputLog)) { + if (renderEmacsPackageBrowser(state.emacsState.emacsPackages, + state.emacsState.emacs, + state.notifications)) { state.emacsState.emacsFunctionIndexDirty = true; } ImGui::PopFont(); @@ -188,7 +190,9 @@ static void renderMinibuffer(EditorState& state) { state.emacsState.emacsKeys.minibufferBuf, sizeof(state.emacsState.emacsKeys.minibufferBuf), ImGuiInputTextFlags_EnterReturnsTrue)) { - emacsExecuteMinibuffer(state.emacsState.emacsKeys, state.emacsState.emacs, state.outputLog); + emacsExecuteMinibuffer(state.emacsState.emacsKeys, + state.emacsState.emacs, + state.notifications); } ImGui::PopFont(); ImGui::End(); @@ -237,7 +241,8 @@ static void renderMemoryStrategiesPanel(EditorState& state) { if (ImGui::Button("Export JSON")) { auto j = buildAnnotationSummaryJson(entries); - state.outputLog += "Annotation summary:\\n" + j.dump(2) + "\\n"; + state.notify(NotificationLevel::Info, + "Annotation summary:\\n" + j.dump(2)); } } ImGui::PopFont(); diff --git a/editor/src/panels/StatusBarPanel.h b/editor/src/panels/StatusBarPanel.h index 84e0186..ef98e3e 100644 --- a/editor/src/panels/StatusBarPanel.h +++ b/editor/src/panels/StatusBarPanel.h @@ -68,6 +68,16 @@ static void renderStatusBar(EditorState& state) { ImGui::SameLine(0, 30); ImGui::Text("UTF-8"); + ImGui::SameLine(0, 30); + int unread = state.notifications.unreadCount(); + std::string notifLabel = "Notifications"; + if (unread > 0) { + notifLabel += " (" + std::to_string(unread) + ")"; + } + if (ImGui::Button(notifLabel.c_str())) { + state.notifications.showHistory = !state.notifications.showHistory; + } + ImGui::PopFont(); ImGui::End(); ImGui::PopStyleVar(); diff --git a/editor/tests/step169_integration_test.cpp b/editor/tests/step169_integration_test.cpp new file mode 100644 index 0000000..7d52902 --- /dev/null +++ b/editor/tests/step169_integration_test.cpp @@ -0,0 +1,41 @@ +// Step 169: Notification system integration wiring 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/NotificationSystem.h")); + + const std::string editorState = readFile("src/EditorState.h"); + assertContains(editorState, "NotificationSystem"); + assertContains(editorState, "notifications"); + + const std::string statusBar = readFile("src/panels/StatusBarPanel.h"); + assertContains(statusBar, "Notifications"); + assertContains(statusBar, "state.notifications"); + + const std::string mainCpp = readFile("src/main.cpp"); + assertContains(mainCpp, "renderHistoryWindow"); + assertContains(mainCpp, "renderToasts"); + + const std::string bottomPanel = readFile("src/panels/BottomPanel.h"); + assertContains(bottomPanel, "state.notifications"); + + printf("step169_integration_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step169_test.cpp b/editor/tests/step169_test.cpp new file mode 100644 index 0000000..ac1c6f7 --- /dev/null +++ b/editor/tests/step169_test.cpp @@ -0,0 +1,48 @@ +// Step 169: Notification system unit checks. + +#include +#include + +#include "NotificationSystem.h" + +static const Notification* findById(const std::vector& list, int id) { + for (const auto& n : list) { + if (n.id == id) return &n; + } + return nullptr; +} + +int main() { + NotificationSystem notifications; + notifications.toastDurationSeconds = 5.0f; + + notifications.notifyAt(NotificationLevel::Info, "hello", 1.0); + assert(notifications.getHistory().size() == 1); + assert(notifications.unreadCount() == 1); + + NotificationTarget target; + target.path = "C:\\temp\\example.cpp"; + target.line = 2; + target.col = 5; + notifications.notifyAt(NotificationLevel::Error, "boom", 2.0, &target); + assert(notifications.getHistory().size() == 2); + + const Notification& last = notifications.getHistory().back(); + assert(last.hasTarget); + assert(last.target.path == target.path); + assert(last.target.line == target.line); + assert(last.target.col == target.col); + + int id = last.id; + notifications.dismiss(id); + const Notification* found = findById(notifications.getHistory(), id); + assert(found); + assert(found->dismissed); + assert(found->read); + + notifications.markAllRead(); + assert(notifications.unreadCount() == 0); + + printf("step169_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index ca0f732..7398786 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -63,7 +63,7 @@ first — all UX work becomes easier when panels are modular. All files under 600 lines after split. *Modifies:* 5+ oversized headers -- [ ] **Step 169: Notification / toast system** +- [x] **Step 169: Notification / toast system** Replace ad-hoc `outputLog +=` with a structured notification system. `NotificationSystem.h`: - Notification types: Success, Warning, Error, Info