Step 354: Menu Bar with Key Symbols (12/12 tests)
This commit is contained in:
@@ -2110,4 +2110,8 @@ add_executable(step353_test tests/step353_test.cpp)
|
||||
target_include_directories(step353_test PRIVATE src)
|
||||
target_link_libraries(step353_test PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
add_executable(step354_test tests/step354_test.cpp)
|
||||
target_include_directories(step354_test PRIVATE src)
|
||||
target_link_libraries(step354_test PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
|
||||
|
||||
217
editor/src/MenuBar.h
Normal file
217
editor/src/MenuBar.h
Normal file
@@ -0,0 +1,217 @@
|
||||
#pragma once
|
||||
// Step 354: Menu Bar with key symbols.
|
||||
// Headless data model used by tests and UI integration points.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "KeySymbolRenderer.h"
|
||||
|
||||
struct MenuItem {
|
||||
std::string id;
|
||||
std::string label;
|
||||
std::string shortcut; // text form, e.g. Ctrl+S
|
||||
std::string symbols; // symbol/text depending platform, e.g. ⌃S
|
||||
bool isSeparator = false;
|
||||
bool enabled = true;
|
||||
bool hasSubmenu = false;
|
||||
std::vector<MenuItem> submenuItems;
|
||||
|
||||
static MenuItem separator() {
|
||||
MenuItem m;
|
||||
m.isSeparator = true;
|
||||
return m;
|
||||
}
|
||||
};
|
||||
|
||||
struct Menu {
|
||||
std::string title;
|
||||
std::vector<MenuItem> items;
|
||||
};
|
||||
|
||||
class MenuBar {
|
||||
public:
|
||||
void addMenu(const Menu& menu) { menus_.push_back(menu); }
|
||||
|
||||
const std::vector<Menu>& getMenus() const { return menus_; }
|
||||
|
||||
size_t menuCount() const { return menus_.size(); }
|
||||
|
||||
const Menu* getMenu(const std::string& title) const {
|
||||
for (const auto& m : menus_) {
|
||||
if (m.title == title) return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool hasMenu(const std::string& title) const {
|
||||
return getMenu(title) != nullptr;
|
||||
}
|
||||
|
||||
MenuItem findItem(const std::string& id) const {
|
||||
for (const auto& menu : menus_) {
|
||||
for (const auto& item : menu.items) {
|
||||
if (item.id == id) return item;
|
||||
if (item.hasSubmenu) {
|
||||
for (const auto& sub : item.submenuItems) {
|
||||
if (sub.id == id) return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool hasItem(const std::string& id) const {
|
||||
return !findItem(id).id.empty();
|
||||
}
|
||||
|
||||
int separatorCount(const std::string& menuTitle) const {
|
||||
const Menu* menu = getMenu(menuTitle);
|
||||
if (!menu) return 0;
|
||||
int count = 0;
|
||||
for (const auto& item : menu->items) {
|
||||
if (item.isSeparator) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool hasSubmenu(const std::string& itemId) const {
|
||||
for (const auto& menu : menus_) {
|
||||
for (const auto& item : menu.items) {
|
||||
if (item.id == itemId) return item.hasSubmenu && !item.submenuItems.empty();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void openMenu(const std::string& title) {
|
||||
openMenu_ = title;
|
||||
}
|
||||
|
||||
bool isMenuOpen(const std::string& title) const {
|
||||
return openMenu_ == title;
|
||||
}
|
||||
|
||||
bool isAnyMenuOpen() const { return !openMenu_.empty(); }
|
||||
|
||||
std::optional<std::string> selectItem(const std::string& itemId) {
|
||||
MenuItem item = findItem(itemId);
|
||||
if (item.id.empty() || !item.enabled || item.isSeparator) return std::nullopt;
|
||||
openMenu_.clear(); // selecting an action closes menus
|
||||
return item.id;
|
||||
}
|
||||
|
||||
static MenuBar buildDefaults(const KeybindingRegistry& reg,
|
||||
const WhetstoneTheme& theme = getDefaultDarkTheme()) {
|
||||
MenuBar bar;
|
||||
|
||||
auto makeAction = [&](const std::string& id, const std::string& fallback) -> MenuItem {
|
||||
MenuItem item;
|
||||
item.id = id;
|
||||
item.label = fallback;
|
||||
|
||||
for (const auto& action : reg.getActions()) {
|
||||
if (action.id == id) {
|
||||
item.label = action.label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
auto layout = KeySymbolRenderer::renderActionWithKey(item.label, id, reg, theme);
|
||||
item.shortcut = layout.shortcut;
|
||||
item.symbols = layout.symbols;
|
||||
return item;
|
||||
};
|
||||
|
||||
// File
|
||||
{
|
||||
Menu m;
|
||||
m.title = "File";
|
||||
m.items.push_back(makeAction("new-file", "New"));
|
||||
m.items.push_back(makeAction("open-file", "Open File"));
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(makeAction("save-buffer", "Save"));
|
||||
m.items.push_back(makeAction("save-all", "Save All"));
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(makeAction("close-buffer", "Close"));
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
// Edit
|
||||
{
|
||||
Menu m;
|
||||
m.title = "Edit";
|
||||
m.items.push_back(makeAction("undo", "Undo"));
|
||||
m.items.push_back(makeAction("redo", "Redo"));
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(makeAction("find-in-file", "Find"));
|
||||
m.items.push_back(makeAction("find-in-project", "Find in Project"));
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
// View
|
||||
{
|
||||
Menu m;
|
||||
m.title = "View";
|
||||
m.items.push_back(makeAction("toggle-file-tree", "Toggle File Tree"));
|
||||
m.items.push_back(makeAction("toggle-ast-view", "Toggle AST"));
|
||||
m.items.push_back(makeAction("toggle-diagnostics", "Toggle Diagnostics"));
|
||||
m.items.push_back(makeAction("toggle-annotations", "Toggle Annotations"));
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(makeAction("reset-layout", "Reset Layout"));
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
// Tools
|
||||
{
|
||||
Menu m;
|
||||
m.title = "Tools";
|
||||
m.items.push_back(makeAction("run-pipeline", "Run Pipeline"));
|
||||
m.items.push_back(makeAction("generate-code", "Generate Code"));
|
||||
m.items.push_back(makeAction("infer-annotations", "Infer Annotations"));
|
||||
m.items.push_back(makeAction("validate", "Validate"));
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(makeAction("export-semanno", "Export Semanno"));
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
// Workflow
|
||||
{
|
||||
Menu m;
|
||||
m.title = "Workflow";
|
||||
m.items.push_back(makeAction("create-workflow", "Create Workflow"));
|
||||
m.items.push_back(makeAction("route-all", "Route All"));
|
||||
m.items.push_back(makeAction("view-ready-tasks", "View Ready Tasks"));
|
||||
m.items.push_back(makeAction("save-workflow", "Save Workflow"));
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
// Help with submenu
|
||||
{
|
||||
Menu m;
|
||||
m.title = "Help";
|
||||
MenuItem about = makeAction("about", "About");
|
||||
MenuItem shortcuts = makeAction("keyboard-shortcuts", "Keyboard Shortcuts");
|
||||
|
||||
MenuItem docs;
|
||||
docs.id = "docs";
|
||||
docs.label = "Documentation";
|
||||
docs.hasSubmenu = true;
|
||||
docs.submenuItems.push_back(makeAction("docs-getting-started", "Getting Started"));
|
||||
docs.submenuItems.push_back(makeAction("docs-keybindings", "Keybindings"));
|
||||
|
||||
m.items.push_back(about);
|
||||
m.items.push_back(shortcuts);
|
||||
m.items.push_back(MenuItem::separator());
|
||||
m.items.push_back(docs);
|
||||
bar.addMenu(m);
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<Menu> menus_;
|
||||
std::string openMenu_;
|
||||
};
|
||||
136
editor/tests/step354_test.cpp
Normal file
136
editor/tests/step354_test.cpp
Normal file
@@ -0,0 +1,136 @@
|
||||
// Step 354: Menu Bar with Key Symbols (12 tests)
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include "MenuBar.h"
|
||||
#include "KeybindingRegistry.h"
|
||||
|
||||
int main() {
|
||||
int passed = 0;
|
||||
KeybindingRegistry reg;
|
||||
reg.loadDefaults();
|
||||
MenuBar bar = MenuBar::buildDefaults(reg);
|
||||
|
||||
// Test 1: all 5 core menus render (+ Help)
|
||||
{
|
||||
assert(bar.hasMenu("File"));
|
||||
assert(bar.hasMenu("Edit"));
|
||||
assert(bar.hasMenu("View"));
|
||||
assert(bar.hasMenu("Tools"));
|
||||
assert(bar.hasMenu("Workflow"));
|
||||
assert(bar.hasMenu("Help"));
|
||||
std::cout << "Test 1 PASSED: all menus render\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 2: File menu Save has Ctrl+S
|
||||
{
|
||||
MenuItem save = bar.findItem("save-buffer");
|
||||
assert(save.id == "save-buffer");
|
||||
assert(save.shortcut == "Ctrl+S");
|
||||
std::cout << "Test 2 PASSED: File Save shortcut\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 3: Edit menu Undo has Ctrl+Z
|
||||
{
|
||||
MenuItem undo = bar.findItem("undo");
|
||||
assert(undo.id == "undo");
|
||||
assert(undo.shortcut == "Ctrl+Z");
|
||||
std::cout << "Test 3 PASSED: Edit Undo shortcut\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 4: View menu panel toggles present
|
||||
{
|
||||
assert(bar.hasItem("toggle-file-tree"));
|
||||
assert(bar.hasItem("toggle-ast-view"));
|
||||
assert(bar.hasItem("toggle-diagnostics"));
|
||||
assert(bar.hasItem("toggle-annotations"));
|
||||
std::cout << "Test 4 PASSED: View panel toggles present\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 5: Tools menu run pipeline present
|
||||
{
|
||||
MenuItem run = bar.findItem("run-pipeline");
|
||||
assert(run.id == "run-pipeline");
|
||||
assert(run.label.find("Run") != std::string::npos);
|
||||
std::cout << "Test 5 PASSED: Tools run pipeline present\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 6: menu item executes correct action id
|
||||
{
|
||||
bar.openMenu("File");
|
||||
auto selected = bar.selectItem("save-buffer");
|
||||
assert(selected.has_value());
|
||||
assert(selected.value() == "save-buffer");
|
||||
std::cout << "Test 6 PASSED: menu selection executes action\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 7: key symbols visible on bound actions
|
||||
{
|
||||
MenuItem save = bar.findItem("save-buffer");
|
||||
MenuItem run = bar.findItem("run-pipeline");
|
||||
assert(!save.symbols.empty());
|
||||
assert(!run.symbols.empty());
|
||||
std::cout << "Test 7 PASSED: key symbols visible\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 8: menu closes after selection
|
||||
{
|
||||
bar.openMenu("Edit");
|
||||
assert(bar.isMenuOpen("Edit"));
|
||||
auto selected = bar.selectItem("undo");
|
||||
assert(selected.has_value());
|
||||
assert(!bar.isAnyMenuOpen());
|
||||
std::cout << "Test 8 PASSED: menu closes after selection\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 9: separators between groups
|
||||
{
|
||||
assert(bar.separatorCount("File") >= 2);
|
||||
assert(bar.separatorCount("Edit") >= 1);
|
||||
assert(bar.separatorCount("Tools") >= 1);
|
||||
std::cout << "Test 9 PASSED: separators between groups\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 10: workflow menu items present
|
||||
{
|
||||
assert(bar.hasItem("create-workflow"));
|
||||
assert(bar.hasItem("route-all"));
|
||||
assert(bar.hasItem("view-ready-tasks"));
|
||||
assert(bar.hasItem("save-workflow"));
|
||||
std::cout << "Test 10 PASSED: workflow items present\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 11: submenu rendering metadata
|
||||
{
|
||||
assert(bar.hasSubmenu("docs"));
|
||||
assert(bar.hasItem("docs-getting-started"));
|
||||
assert(bar.hasItem("docs-keybindings"));
|
||||
std::cout << "Test 11 PASSED: submenu metadata\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 12: selecting separator/unknown is ignored
|
||||
{
|
||||
auto bad = bar.selectItem("not-found");
|
||||
assert(!bad.has_value());
|
||||
auto sep = bar.selectItem("");
|
||||
assert(!sep.has_value());
|
||||
std::cout << "Test 12 PASSED: invalid selections ignored\n";
|
||||
passed++;
|
||||
}
|
||||
|
||||
std::cout << "\nResults: " << passed << "/12\n";
|
||||
assert(passed == 12);
|
||||
return 0;
|
||||
}
|
||||
37
progress.md
37
progress.md
@@ -1790,6 +1790,43 @@ ids, categories, and aliases. Added population helpers that pull actions from
|
||||
- `step346_test` — PASS (8/8) layout + palette integration regression
|
||||
- `step353_test` — PASS (12/12) new step coverage
|
||||
|
||||
### Step 354: Menu Bar with Key Symbols
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
Added a structured menu bar model with File/Edit/View/Tools/Workflow/Help menus,
|
||||
key-symbol metadata on actions, separator grouping, action selection handling,
|
||||
and submenu support for help/documentation. Menu action labels/shortcuts are
|
||||
derived from `KeybindingRegistry` and rendered via `KeySymbolRenderer` so the
|
||||
symbol display path is consistent with step 352.
|
||||
|
||||
**Files created:**
|
||||
- `editor/src/MenuBar.h` — headless menu model:
|
||||
- `MenuItem` / `Menu` / `MenuBar`
|
||||
- `buildDefaults(...)` for the full menu set
|
||||
- selection API (`openMenu`, `selectItem`) with close-on-select behavior
|
||||
- submenu metadata support and separator counting helpers
|
||||
- `editor/tests/step354_test.cpp` — 12 tests covering:
|
||||
1. core menus present
|
||||
2. File Save shortcut
|
||||
3. Edit Undo shortcut
|
||||
4. View panel toggle actions
|
||||
5. Tools pipeline action
|
||||
6. correct action execution id
|
||||
7. key symbol visibility
|
||||
8. menu close after selection
|
||||
9. separator grouping
|
||||
10. workflow action presence
|
||||
11. submenu metadata
|
||||
12. invalid selection handling
|
||||
|
||||
**Files modified:**
|
||||
- `editor/CMakeLists.txt` — `step354_test` target
|
||||
|
||||
**Verification run:**
|
||||
- `step352_test` — PASS (12/12) regression coverage
|
||||
- `step353_test` — PASS (12/12) regression coverage
|
||||
- `step354_test` — PASS (12/12) new step coverage
|
||||
|
||||
# Roadmap Planning — Sprints 12-25+
|
||||
|
||||
## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)
|
||||
|
||||
Reference in New Issue
Block a user