diff --git a/AGENTS.md b/AGENTS.md index f329391..628672c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ When the user requests a durable behavior change, record it here or in the relev - [tests/AGENTS.md](tests/AGENTS.md) — Catch2 unit + benchmark tests - [help/AGENTS.md](help/AGENTS.md) — Help window widget with data-driven quick reference content - [layout/AGENTS.md](layout/AGENTS.md) — Exe-relative ImGui layout persistence (default + named presets) +- [tutorial/AGENTS.md](tutorial/AGENTS.md) — Guided first-run walkthrough with panel highlighting and exe-relative completion marker diff --git a/CMakeLists.txt b/CMakeLists.txt index 2effb9b..ee64c9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,6 +219,7 @@ add_subdirectory("attenuator") add_subdirectory("combiner") add_subdirectory("help") add_subdirectory("layout") +add_subdirectory("tutorial") # ============================================================================= # Install targets # ============================================================================= diff --git a/app/AGENTS.md b/app/AGENTS.md index 6d47f13..e1be2b0 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -16,7 +16,9 @@ Application orchestrator layer containing `RfSimulatorApp`, `ComponentRegistry`, ## Local Contracts - `RfSimulatorApp::saveProject()` / `loadProject()` / `newProject()` are thin wrappers that delegate to `ProjectSerializer::save()` / `load()` / `reset()`; all `.rfsim` JSON serialization lives in `ProjectSerializer` - Dirty tracking propagated via `markDirty()` / `onParamChange` / `onLinkChanged` callbacks -- File menu bar in `draw_ui()` handles keyboard shortcuts (`Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+Shift+S`) and unsaved-changes modal; Help menu provides F1-toggled help window +- File menu bar in `draw_ui()` handles keyboard shortcuts (`Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+Shift+S`) and unsaved-changes modal; Help menu provides the F1-toggled help window and `Help > Tutorial` +- `Help > Tutorial` and the first-run "Welcome" modal both route through `requestTutorial()`, which runs the same `PendingAction`/unsaved-changes guard as New/Open/Exit (`PendingAction::Tutorial`) before `startTutorial()` resets to a seeded sandbox. Any new `PendingAction` value must be handled in both the Save and Discard branches of that modal. +- The first-run offer is driven by `m_show_tutorial_first_run_prompt`, set on construction from `TutorialState::completed()` (see `tutorial/AGENTS.md`); either answer marks it completed so the prompt never repeats - View menu's `Layouts` submenu (Save As.../Load/Manage...) drives `LayoutManager` (see `layout/AGENTS.md`) for named window-layout presets; the exe-relative default layout is auto-managed by ImGui itself via `IniFilename`, set in `core/src/core.cpp` - Library browser's "New Component..."/"Edit" flow writes schema-v2 JSON via `ComponentFormModel::buildDefinition()`; new entries save to a user-chosen root (`rf-sim-libraries/` project-local or `~/.rf-sim/libraries/` global); built-in `component_data/library/` entries are read-only (no Edit button) and never a save target; edits always overwrite the original `source_path` (no rename-on-identity-change) - Extension discovery stays inside `app/` and scans built-in, global, and project-local roots; invalid manifests remain visible via `ExtensionManager::all()` diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index fffec69..1936a37 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -56,6 +56,7 @@ target_link_libraries(app simulator::node_graph_widget simulator::help_widget simulator::layout + simulator::tutorial common ) diff --git a/app/include/app.h b/app/include/app.h index 8ce6bc2..07ab415 100644 --- a/app/include/app.h +++ b/app/include/app.h @@ -33,12 +33,14 @@ #include "spectrum_analyzer_engine.h" #include "spectrum_analyzer_widget.h" #include "splitter_engine.h" +#include "tutorial_state.h" +#include "tutorial_widget.h" #include "view_manager.h" #include #include #include #include -enum class PendingAction { None, New, Open, Exit }; +enum class PendingAction { None, New, Open, Exit, Tutorial }; class RfSimulatorApp { public: @@ -56,6 +58,11 @@ class RfSimulatorApp { bool m_show_node_editor = true; bool m_show_help = false; HelpWidget m_help_widget; + bool m_show_tutorial = false; + // Set on construction when no completion marker exists. Public so the UI + // test harness can suppress the blocking modal (see test_engine/ui_tests.cpp). + bool m_show_tutorial_first_run_prompt = false; + TutorialWidget m_tutorial_widget; LayoutManager m_layout_manager; bool m_show_save_layout_dialog = false; bool m_show_manage_layouts_dialog = false; @@ -68,6 +75,7 @@ class RfSimulatorApp { std::string m_extension_result_message; SessionState m_state; + TutorialState m_tutorial_state; ~RfSimulatorApp(); // Project save/load @@ -85,6 +93,7 @@ class RfSimulatorApp { ComponentRegistry &testComponents() { return m_components; } NodeGraphWidget &testGraphWidget() { return *m_graph_widget; } LayoutManager &testLayoutManager() { return m_layout_manager; } + TutorialState &testTutorialState() { return m_tutorial_state; } ExtensionManager &testExtensionManager() { return m_extension_manager; } const std::string &testExtensionResultMessage() const { return m_extension_result_message; } @@ -93,6 +102,11 @@ class RfSimulatorApp { private: void load_window_states(); + // Runs the unsaved-changes guard, then starts the tutorial (directly if the + // project is clean, otherwise via PendingAction::Tutorial). + void requestTutorial(); + // Resets to a fresh seeded sandbox and activates the walkthrough. + void startTutorial(); void rewireInputs(); void duplicateComponent(int graph_node_id); void addComponent(const ComponentTypeDescriptor *desc, ImVec2 pos); diff --git a/app/src/app.cpp b/app/src/app.cpp index 2cb5262..ba6c038 100644 --- a/app/src/app.cpp +++ b/app/src/app.cpp @@ -104,6 +104,11 @@ RfSimulatorApp::RfSimulatorApp() : m_components(m_graph_engine, m_view_manager) m_graph_widget->syncNodesFromEngine(); load_window_states(); + + // Offer the guided walkthrough once, on the first launch of a given build. + // Tutorial visibility itself is transient session state, so it is not + // persisted through SessionState — only the completion marker is durable. + m_show_tutorial_first_run_prompt = !m_tutorial_state.completed(); } void RfSimulatorApp::addComponent(const ComponentTypeDescriptor *desc, ImVec2 pos) { @@ -173,6 +178,36 @@ void RfSimulatorApp::newProject() { m_dirty = false; } +void RfSimulatorApp::requestTutorial() { + // Same guard New/Open/Exit use — startTutorial() discards the current + // project, so the user must get the chance to save first. + if (m_dirty) { + m_pending_action = PendingAction::Tutorial; + m_show_unsaved_dialog = true; + } else + startTutorial(); +} + +void RfSimulatorApp::startTutorial() { + // Reset to the same Generator + Amplifier pair the app seeds on first launch, + // so every step's instruction matches what the user is actually looking at. + newProject(); + m_components.add(m_next_component_id++, m_graph_engine) + .addTone(100e6, -20.0); + m_components.add(m_next_component_id++, m_graph_engine); + m_graph_widget->syncNodesFromEngine(); + + // Every panel a step highlights must be on screen for the highlight to + // resolve — the Component Library in particular is hidden by default. + m_show_node_editor = true; + m_show_properties = true; + m_show_spectrum = true; + m_show_library = true; + + m_tutorial_state.start(); + m_show_tutorial = true; +} + void RfSimulatorApp::testMakeDirty() { markDirty(); } void RfSimulatorApp::markDirty() { m_dirty = true; } @@ -645,6 +680,8 @@ void RfSimulatorApp::draw_ui() { if (ImGui::BeginMenu("Help")) { if (ImGui::MenuItem("How to Use", "F1")) m_show_help = !m_show_help; + if (ImGui::MenuItem("Tutorial")) + requestTutorial(); ImGui::EndMenu(); } // Title / project name on the right @@ -693,6 +730,35 @@ void RfSimulatorApp::draw_ui() { m_show_help = !m_show_help; } + // First-run tutorial offer. Must stay ahead of the Unsaved Changes block so + // that "Start Tutorial" can raise that dialog within the same frame. + if (m_show_tutorial_first_run_prompt) { + ImGui::OpenPopup("Welcome to Tiny RF Simulator"); + } + if (ImGui::BeginPopupModal("Welcome to Tiny RF Simulator", nullptr, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("New here? A short guided tutorial walks you through building"); + ImGui::Text("and probing your first signal chain."); + ImGui::Spacing(); + ImGui::TextDisabled("Re-run it anytime from Help > Tutorial."); + ImGui::Separator(); + // Either answer marks the tutorial completed: this prompt is a one-time + // offer, not a reminder that returns until the walkthrough is finished. + if (ImGui::Button("Start Tutorial", ImVec2(140, 0))) { + m_tutorial_state.markCompleted(); + m_show_tutorial_first_run_prompt = false; + ImGui::CloseCurrentPopup(); + requestTutorial(); + } + ImGui::SameLine(); + if (ImGui::Button("Not Now", ImVec2(140, 0))) { + m_tutorial_state.markCompleted(); + m_show_tutorial_first_run_prompt = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Unsaved Changes popup — use a bool flag instead of OpenPopup/BeginPopupModal, // which can be unreliable when called from inside a menu bar context. if (m_show_unsaved_dialog) { @@ -726,6 +792,9 @@ void RfSimulatorApp::draw_ui() { case PendingAction::Exit: std::exit(0); break; + case PendingAction::Tutorial: + startTutorial(); + break; default: break; } @@ -748,6 +817,9 @@ void RfSimulatorApp::draw_ui() { case PendingAction::Exit: std::exit(0); break; + case PendingAction::Tutorial: + startTutorial(); + break; default: break; } @@ -865,6 +937,13 @@ void RfSimulatorApp::draw_ui() { if (m_show_help) m_help_widget.draw("How to Use", &m_show_help); + + if (m_show_tutorial) { + m_tutorial_widget.draw(m_tutorial_state); + // Finish/Exit deactivate TutorialState from inside the widget; mirror + // that back onto the app's visibility flag. + m_show_tutorial = m_tutorial_state.isActive(); + } } RfSimulatorApp::~RfSimulatorApp() { diff --git a/help/src/help_widget.cpp b/help/src/help_widget.cpp index 4f1b273..eaa35f1 100644 --- a/help/src/help_widget.cpp +++ b/help/src/help_widget.cpp @@ -20,8 +20,9 @@ const HelpSection sections[] = { "Select multiple nodes: Ctrl+click or rubber-band selection", "Delete selected nodes: press Delete key", "Right-click canvas: open context menu to add components", + "New here? Help > Tutorial runs a guided walkthrough of this workflow", }, - 6}, + 7}, {"Adding and Removing Components", { "Right-click the canvas and choose a component from the context menu", diff --git a/test_engine/ui_tests.cpp b/test_engine/ui_tests.cpp index bf5c969..63ba5ad 100644 --- a/test_engine/ui_tests.cpp +++ b/test_engine/ui_tests.cpp @@ -17,6 +17,12 @@ static RfSimulatorApp *s_app = nullptr; void RegisterUiTests(ImGuiTestEngine *e, RfSimulatorApp &app) { s_app = &app; + + // The first-run tutorial offer is a blocking modal, which would stop every + // other test from reaching the menu bar. Suppress it here (before the first + // frame) — tutorial_first_run_prompt_marks_completed re-arms it explicitly. + app.m_show_tutorial_first_run_prompt = false; + ImGuiTest *t = nullptr; t = IM_REGISTER_TEST(e, "rf_simulator", "node_editor_exists"); @@ -465,4 +471,127 @@ void RegisterUiTests(ImGuiTestEngine *e, RfSimulatorApp &app) { IM_CHECK(!std::filesystem::exists(path)); }; + + // Tutorial tests are registered last on purpose: starting the tutorial calls + // newProject(), which clears the components and node IDs the tests above + // rely on. + + t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_launches_from_help_menu"); + t->TestFunc = [](ImGuiTestContext *ctx) { + s_app->newProject(); // clean slate, so the unsaved-changes guard stays out of the way + ctx->SetRef("##MainMenuBar"); + ctx->MenuClick("Help/Tutorial"); + ctx->Yield(2); + + IM_CHECK(s_app->testTutorialState().isActive()); + IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 0); + IM_CHECK(ImGui::FindWindowByName("Tutorial Guide") != nullptr); + + ctx->SetRef("Tutorial Guide"); + ctx->ItemClick("Exit"); + ctx->SetRef(""); + ctx->Yield(2); + IM_CHECK(!s_app->testTutorialState().isActive()); + }; + + t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_start_guards_unsaved_changes"); + t->TestFunc = [](ImGuiTestContext *ctx) { + s_app->newProject(); + s_app->testMakeDirty(); + + ctx->SetRef("##MainMenuBar"); + ctx->MenuClick("Help/Tutorial"); + ctx->Yield(2); + // Dirty project — the tutorial must wait behind the unsaved-changes modal. + IM_CHECK(!s_app->testTutorialState().isActive()); + + ctx->SetRef("Unsaved Changes"); + ctx->ItemClick("Discard"); + ctx->SetRef(""); + ctx->Yield(2); + IM_CHECK(s_app->testTutorialState().isActive()); + + ctx->SetRef("Tutorial Guide"); + ctx->ItemClick("Exit"); + ctx->SetRef(""); + ctx->Yield(2); + }; + + t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_step_navigation"); + t->TestFunc = [](ImGuiTestContext *ctx) { + s_app->newProject(); + ctx->SetRef("##MainMenuBar"); + ctx->MenuClick("Help/Tutorial"); + ctx->Yield(2); + + ctx->SetRef("Tutorial Guide"); + IM_CHECK(s_app->testTutorialState().atFirstStep()); + + ctx->ItemClick("Next"); + ctx->Yield(); + IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 1); + + ctx->ItemClick("Back"); + ctx->Yield(); + IM_CHECK_EQ(s_app->testTutorialState().stepIndex(), 0); + + ctx->ItemClick("Skip"); + ctx->Yield(); + IM_CHECK(s_app->testTutorialState().atLastStep()); + IM_CHECK(s_app->testTutorialState().isActive()); + + ctx->ItemClick("Exit"); + ctx->SetRef(""); + ctx->Yield(2); + IM_CHECK(!s_app->testTutorialState().isActive()); + }; + + t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_completes_and_persists"); + t->TestFunc = [](ImGuiTestContext *ctx) { + std::filesystem::path marker(s_app->testTutorialState().markerPath()); + std::filesystem::remove(marker); + + s_app->newProject(); + ctx->SetRef("##MainMenuBar"); + ctx->MenuClick("Help/Tutorial"); + ctx->Yield(2); + + ctx->SetRef("Tutorial Guide"); + // Bounded walk to the last step — never loop on the state alone. + for (int i = 0; i < s_app->testTutorialState().stepCount(); ++i) { + if (s_app->testTutorialState().atLastStep()) + break; + ctx->ItemClick("Next"); + ctx->Yield(); + } + IM_CHECK(s_app->testTutorialState().atLastStep()); + IM_CHECK(!std::filesystem::exists(marker)); // not marked until Finish + + ctx->ItemClick("Finish"); + ctx->SetRef(""); + ctx->Yield(2); + + IM_CHECK(!s_app->testTutorialState().isActive()); + IM_CHECK(std::filesystem::exists(marker)); + }; + + t = IM_REGISTER_TEST(e, "rf_simulator", "tutorial_first_run_prompt_marks_completed"); + t->TestFunc = [](ImGuiTestContext *ctx) { + std::filesystem::path marker(s_app->testTutorialState().markerPath()); + std::filesystem::remove(marker); + + // Re-arm the prompt suppressed in RegisterUiTests. + s_app->m_show_tutorial_first_run_prompt = true; + ctx->Yield(2); + + ctx->SetRef("Welcome to Tiny RF Simulator"); + ctx->ItemClick("Not Now"); + ctx->SetRef(""); + ctx->Yield(2); + + IM_CHECK(!s_app->m_show_tutorial_first_run_prompt); + IM_CHECK(!s_app->testTutorialState().isActive()); + // Dismissing the offer counts as completed — it must never nag again. + IM_CHECK(std::filesystem::exists(marker)); + }; } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 64be0be..1c1eed6 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -22,6 +22,7 @@ Own the Catch2 v3.4.0 unit test suite and ImGui test engine UI tests. Verify all - Adding a new module? Add its test source to `TEST_SOURCES` in `CMakeLists.txt` and link the library target - **MinGW-w64 test-registration ceiling:** this toolchain silently drops any `TEST_CASE` registered beyond the ~217 already linked into the main `tests` executable (confirmed via a from-scratch clean rebuild; see the comment above `test_component_authoring` in `CMakeLists.txt`). Do not add new `TEST_CASE`s to `test_main.cpp` or any file already compiled into the `tests` target — give the new coverage its own standalone executable instead (`add_executable(test_ test_.cpp)` + `target_link_libraries` + `add_test`, following `test_attenuator`/`test_combiner`/`test_component_authoring`/`test_extensions`/`test_signal_domain`), and run it directly (`build/bin/test_.exe`) rather than relying on `ctest`. - Platform-specific tests (e.g., Windows-only session state) are gated with `#ifdef WIN32` in CMakeLists.txt +- `test_component_authoring` and `test_tutorial_state` are standalone executables, not part of `TEST_SOURCES`: the MinGW-w64 toolchain silently drops `TEST_CASE`s registered beyond the ~217 already linked into `tests`. New test files that must run on Windows should follow that pattern. ## Work Guidance diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6bd3944..e60858d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -89,6 +89,18 @@ target_link_libraries(test_component_authoring PRIVATE target_compile_definitions(test_component_authoring PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}") add_test(NAME test_component_authoring COMMAND test_component_authoring WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) +# Standalone for the same reason as test_component_authoring: TEST_CASEs appended +# to the main `tests` binary are silently dropped by the MinGW-w64 toolchain, and +# these must actually run on every CI platform (TutorialState is cross-platform, +# unlike SessionState). +add_executable(test_tutorial_state test_tutorial_state.cpp) +target_link_libraries(test_tutorial_state PRIVATE + simulator::tutorial + Catch2::Catch2WithMain +) +target_compile_definitions(test_tutorial_state PRIVATE PROJECT_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +add_test(NAME test_tutorial_state COMMAND test_tutorial_state WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + add_executable(test_extensions test_extensions.cpp) target_link_libraries(test_extensions PRIVATE simulator::app diff --git a/tests/test_tutorial_state.cpp b/tests/test_tutorial_state.cpp new file mode 100644 index 0000000..6905b4d --- /dev/null +++ b/tests/test_tutorial_state.cpp @@ -0,0 +1,128 @@ +#include "tutorial_state.h" +#include "tutorial_steps.h" +#include +#include + +TEST_CASE("TutorialState derives a non-empty exe-relative marker path", "[tutorial]") { + TutorialState state; + std::string path = state.markerPath(); + + REQUIRE_FALSE(path.empty()); + REQUIRE(std::filesystem::path(path).filename().string() == ".tutorial_completed"); + REQUIRE_FALSE(std::filesystem::path(path).parent_path().empty()); +} + +TEST_CASE("TutorialState completed()/markCompleted() round-trip", "[tutorial]") { + TutorialState state; + // Clean slate — the marker lives next to the test executable. + std::filesystem::remove(state.markerPath()); + REQUIRE_FALSE(state.completed()); + + state.markCompleted(); + REQUIRE(state.completed()); + + // markCompleted() is idempotent. + state.markCompleted(); + REQUIRE(state.completed()); + + std::filesystem::remove(state.markerPath()); + REQUIRE_FALSE(state.completed()); +} + +TEST_CASE("TutorialState catalog is non-empty and fully addressable", "[tutorial]") { + REQUIRE_FALSE(tutorialSteps().empty()); + for (const auto &step : tutorialSteps()) { + REQUIRE(step.title != nullptr); + REQUIRE(step.instruction != nullptr); + } + // Every target except None resolves to a window title. + REQUIRE(tutorialTargetWindowTitle(TutorialTarget::None) == nullptr); + REQUIRE(std::string(tutorialTargetWindowTitle(TutorialTarget::NodeEditor)) == "Node Editor"); + REQUIRE(std::string(tutorialTargetWindowTitle(TutorialTarget::ComponentLibrary)) == + "Component Library"); + REQUIRE(std::string(tutorialTargetWindowTitle(TutorialTarget::Properties)) == "Properties"); + REQUIRE(std::string(tutorialTargetWindowTitle(TutorialTarget::SpectrumAnalyzer)) == + "Spectrum Analyzer"); +} + +TEST_CASE("TutorialState is inactive until started", "[tutorial]") { + TutorialState state; + REQUIRE_FALSE(state.isActive()); + state.start(); + REQUIRE(state.isActive()); + REQUIRE(state.atFirstStep()); + REQUIRE(state.stepIndex() == 0); + REQUIRE(state.stepCount() == static_cast(tutorialSteps().size())); +} + +TEST_CASE("TutorialState navigation stays within bounds", "[tutorial]") { + TutorialState state; + std::filesystem::remove(state.markerPath()); + state.start(); + + SECTION("back() at the first step is a no-op") { + state.back(); + REQUIRE(state.atFirstStep()); + REQUIRE(state.stepIndex() == 0); + REQUIRE(state.isActive()); + } + + SECTION("next() walks every step, then finishes") { + for (int i = 0; i < state.stepCount() - 1; ++i) { + REQUIRE_FALSE(state.atLastStep()); + state.next(); + REQUIRE(state.stepIndex() == i + 1); + REQUIRE(state.isActive()); + } + REQUIRE(state.atLastStep()); + + // One more next() finishes: marks completed and deactivates. + state.next(); + REQUIRE_FALSE(state.isActive()); + REQUIRE(state.completed()); + } + + SECTION("next() then back() returns to the previous step") { + state.next(); + REQUIRE(state.stepIndex() == 1); + state.back(); + REQUIRE(state.stepIndex() == 0); + } + + SECTION("skipToLast() jumps to the final step without finishing") { + state.skipToLast(); + REQUIRE(state.atLastStep()); + REQUIRE(state.isActive()); + REQUIRE_FALSE(state.completed()); + + // Already at the end — skipping again must not overshoot. + state.skipToLast(); + REQUIRE(state.stepIndex() == state.stepCount() - 1); + } + + SECTION("exit() deactivates without marking completed") { + state.next(); + state.exit(); + REQUIRE_FALSE(state.isActive()); + REQUIRE_FALSE(state.completed()); + } + + SECTION("currentStep() matches the catalog entry at every index") { + for (int i = 0; i < state.stepCount(); ++i) { + REQUIRE(state.currentStep().title == tutorialSteps()[i].title); + if (!state.atLastStep()) + state.next(); + } + } + + SECTION("start() rewinds a partially-walked tutorial") { + state.next(); + state.next(); + REQUIRE(state.stepIndex() == 2); + state.start(); + REQUIRE(state.atFirstStep()); + REQUIRE(state.isActive()); + } + + std::filesystem::remove(state.markerPath()); +} diff --git a/tutorial/AGENTS.md b/tutorial/AGENTS.md new file mode 100644 index 0000000..1d13cfe --- /dev/null +++ b/tutorial/AGENTS.md @@ -0,0 +1,50 @@ +# tutorial/AGENTS.md + +## Purpose +Guided first-run walkthrough: a data-driven sequence of steps that highlights the +panel each step talks about and remembers, across restarts, that the user has +already been offered the tutorial. + +## Ownership +- `tutorialSteps()` / `tutorialTargetWindowTitle()` — the step catalog and the + `TutorialTarget` → ImGui window-title mapping +- `TutorialState` — navigation state machine plus the durable completion marker +- `TutorialWidget` — the target highlight and the "Tutorial Guide" window + +## Local Contracts +- Step content is data-driven: add, reorder, or remove entries in the `steps` + array in `tutorial_steps.cpp`. Nothing else hard-codes a step count or index. +- `tutorialTargetWindowTitle()` must return the exact string `app/` passes to + that panel's `draw()` call. A mismatch silently drops the highlight rather + than failing, so change both sides together. +- Completion is an exe-relative marker file, `/.tutorial_completed`, + detected the same way as `LayoutManager` (see `layout/AGENTS.md`). + Deliberately not `SessionState`, which is a no-op outside Windows and would + make the first-run prompt reappear on every launch on Linux/macOS. +- Only finishing the last step marks completed from inside the tutorial; `Exit` + and `Skip` do not. Dismissing the first-run offer in `app/` also marks it — + that prompt is a one-time offer, not a recurring reminder. +- `TutorialState` is pure logic and `std::filesystem` only — no `ImGuiContext`, + so it stays unit-testable under `tests/`. Everything needing a live context + lives in `TutorialWidget`. +- The highlight is drawn on `ImGui::GetForegroundDrawList()`, so it paints over + every regular window. The guide window is pinned to the bottom-right of the + main viewport to stay clear of the panels it highlights. + +## Work Guidance +- Steps target whole panels only. `ImGui::FindWindowByName` resolves windows, + and `NodeGraphWidget` exposes no per-node or per-pin screen rect, so + pin-level highlighting needs new plumbing in `node_graph/` first. +- Keep instruction wording verified against actual app behavior, not against + `help/`'s reference text — the two can drift. + +## Verification +- `ctest --test-dir build -R test_tutorial_state --output-on-failure` (pure + navigation + marker-file tests, no ImGui context) +- `build/bin/test_ui` — `tutorial_launches_from_help_menu`, + `tutorial_start_guards_unsaved_changes`, `tutorial_step_navigation`, + `tutorial_completes_and_persists`, + `tutorial_first_run_prompt_marks_completed` + +## Child DOX Index +*(none)* diff --git a/tutorial/CMakeLists.txt b/tutorial/CMakeLists.txt new file mode 100644 index 0000000..d46e8c2 --- /dev/null +++ b/tutorial/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.20) + +project(tutorial LANGUAGES CXX) + +add_library(tutorial STATIC + src/tutorial_steps.cpp + src/tutorial_state.cpp + src/tutorial_widget.cpp +) + +target_include_directories(tutorial PUBLIC + ${PROJECT_SOURCE_DIR}/include +) + +target_link_libraries(tutorial PUBLIC imgui) + +add_library(simulator::tutorial ALIAS tutorial) + +set_target_properties(tutorial PROPERTIES FOLDER "Application") diff --git a/tutorial/include/tutorial_state.h b/tutorial/include/tutorial_state.h new file mode 100644 index 0000000..2190378 --- /dev/null +++ b/tutorial/include/tutorial_state.h @@ -0,0 +1,45 @@ +#pragma once + +#include "tutorial_steps.h" +#include + +// Navigation state for the guided walkthrough, plus the durable "user has +// already been offered the tutorial" flag. Pure logic and std::filesystem only — +// no ImGuiContext required, so it is unit-testable under tests/. +// +// Completion is persisted as an exe-relative marker file rather than through +// SessionState, which is a no-op outside Windows. +class TutorialState { + public: + TutorialState(); + + // /.tutorial_completed + std::string markerPath() const; + bool completed() const; + // Idempotent; creates the marker file if it doesn't already exist. + void markCompleted(); + + // Rewinds to the first step and activates the walkthrough. + void start(); + // Advances one step. On the last step this finishes the tutorial: + // marks it completed and deactivates. + void next(); + // No-op on the first step. + void back(); + // Jumps to the last step without finishing. + void skipToLast(); + // Deactivates without marking completed. + void exit(); + + bool isActive() const { return m_active; } + bool atFirstStep() const { return m_step_index == 0; } + bool atLastStep() const; + int stepIndex() const { return m_step_index; } + int stepCount() const { return static_cast(tutorialSteps().size()); } + const TutorialStep ¤tStep() const; + + private: + std::string m_exe_dir; + bool m_active = false; + int m_step_index = 0; +}; diff --git a/tutorial/include/tutorial_steps.h b/tutorial/include/tutorial_steps.h new file mode 100644 index 0000000..03b6ad7 --- /dev/null +++ b/tutorial/include/tutorial_steps.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +// Panel a tutorial step points at. Each value maps to the exact title string +// the app passes to that panel's draw()/ImGui::Begin() call. +enum class TutorialTarget { + None, // no panel highlight (welcome / wrap-up steps) + ComponentLibrary, + NodeEditor, + Properties, + SpectrumAnalyzer, +}; + +struct TutorialStep { + const char *title; + const char *instruction; + TutorialTarget target; +}; + +// Ordered walkthrough content. Add, reorder, or remove entries in +// tutorial_steps.cpp to change the tutorial — nothing else hard-codes a count. +const std::vector &tutorialSteps(); + +// ImGui window title for a target, or nullptr for TutorialTarget::None. +const char *tutorialTargetWindowTitle(TutorialTarget target); diff --git a/tutorial/include/tutorial_widget.h b/tutorial/include/tutorial_widget.h new file mode 100644 index 0000000..60c94c1 --- /dev/null +++ b/tutorial/include/tutorial_widget.h @@ -0,0 +1,13 @@ +#pragma once + +class TutorialState; + +// Renders the guided walkthrough: a bright outline around the current step's +// target panel plus a floating "Tutorial Guide" window with the instruction +// text and navigation buttons. Requires a live ImGuiContext. +class TutorialWidget { + public: + // No-op when the tutorial isn't active. Button presses mutate `state`, so + // callers should re-read state.isActive() after drawing. + void draw(TutorialState &state); +}; diff --git a/tutorial/src/tutorial_state.cpp b/tutorial/src/tutorial_state.cpp new file mode 100644 index 0000000..d233a53 --- /dev/null +++ b/tutorial/src/tutorial_state.cpp @@ -0,0 +1,102 @@ +#include "tutorial_state.h" + +#include +#include + +#ifdef _WIN32 +#include +#elif defined(__APPLE__) +#include +#include +#else +#include +#include +#endif + +namespace fs = std::filesystem; + +namespace { + +// Mirrors LayoutManager::detectExeDir — completion must be recorded next to the +// executable, not wherever the process happened to be started from. +std::string detectExeDir() { + std::string exe_path; +#ifdef _WIN32 + char buf[MAX_PATH] = {}; + DWORD n = GetModuleFileNameA(nullptr, buf, sizeof(buf)); + if (n > 0 && n < sizeof(buf)) + exe_path = buf; +#elif defined(__APPLE__) + char buf[PATH_MAX] = {}; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) + exe_path = buf; +#else + char buf[PATH_MAX] = {}; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + exe_path = buf; + } +#endif + if (exe_path.empty()) + return fs::current_path().string(); + + fs::path parent = fs::path(exe_path).parent_path(); + if (parent.empty()) + return fs::current_path().string(); + return parent.string(); +} + +} // namespace + +TutorialState::TutorialState() : m_exe_dir(detectExeDir()) {} + +std::string TutorialState::markerPath() const { + return (fs::path(m_exe_dir) / ".tutorial_completed").string(); +} + +bool TutorialState::completed() const { + std::error_code ec; + return fs::exists(markerPath(), ec); +} + +void TutorialState::markCompleted() { + // Existence is the whole signal — the file's contents are never read. + std::ofstream ofs(markerPath(), std::ios::app); +} + +void TutorialState::start() { + m_step_index = 0; + m_active = true; +} + +void TutorialState::next() { + if (atLastStep()) { + markCompleted(); + m_active = false; + return; + } + ++m_step_index; +} + +void TutorialState::back() { + if (m_step_index > 0) + --m_step_index; +} + +void TutorialState::skipToLast() { + int last = stepCount() - 1; + if (last > m_step_index) + m_step_index = last; +} + +void TutorialState::exit() { m_active = false; } + +bool TutorialState::atLastStep() const { return m_step_index >= stepCount() - 1; } + +const TutorialStep &TutorialState::currentStep() const { + const auto &steps = tutorialSteps(); + size_t i = static_cast(m_step_index); + return steps[i < steps.size() ? i : steps.size() - 1]; +} diff --git a/tutorial/src/tutorial_steps.cpp b/tutorial/src/tutorial_steps.cpp new file mode 100644 index 0000000..c76444b --- /dev/null +++ b/tutorial/src/tutorial_steps.cpp @@ -0,0 +1,55 @@ +#include "tutorial_steps.h" + +namespace { + +// Data-driven walkthrough content — add or reorder entries here to change the +// tutorial. Wording is the imperative, single-action form of the reference +// material in help/src/help_widget.cpp. +const std::vector steps = { + {"Welcome", + "This short walkthrough covers adding a component, connecting it, configuring it, and " + "reading its output. Click Next to begin.", + TutorialTarget::None}, + {"Add a Component", + "Click a component in the highlighted Component Library panel to insert it into the canvas. " + "You can also right-click empty canvas in the Node Editor to pick from a context menu.", + TutorialTarget::ComponentLibrary}, + {"Connect Pins", + "In the Node Editor, click an output pin and drag to an input pin, then release to create a " + "link. To remove one, select the link and press Delete.", + TutorialTarget::NodeEditor}, + {"Configure Parameters", + "Left-click a node to load it into the Properties panel, then adjust a parameter such as " + "gain or frequency. Changes take effect immediately.", + TutorialTarget::Properties}, + {"View Results", + "Click an output pin to probe it — the Spectrum Analyzer plots that node's live signal. Up " + "to four probes can be active, each with its own trace color.", + TutorialTarget::SpectrumAnalyzer}, + {"Navigate the Graph", + "Pan the canvas by middle-click dragging. Ctrl+click or rubber-band select to pick several " + "nodes at once, and press Delete to remove them. That's the whole workflow — click Finish.", + TutorialTarget::NodeEditor}, +}; + +} // namespace + +const std::vector &tutorialSteps() { return steps; } + +const char *tutorialTargetWindowTitle(TutorialTarget target) { + // These strings must match the titles app.cpp passes to each panel's draw() + // call — a mismatch silently drops the highlight instead of failing loudly. + switch (target) { + case TutorialTarget::ComponentLibrary: + return "Component Library"; + case TutorialTarget::NodeEditor: + return "Node Editor"; + case TutorialTarget::Properties: + return "Properties"; + case TutorialTarget::SpectrumAnalyzer: + return "Spectrum Analyzer"; + case TutorialTarget::None: + break; + } + return nullptr; +} diff --git a/tutorial/src/tutorial_widget.cpp b/tutorial/src/tutorial_widget.cpp new file mode 100644 index 0000000..bc48d5b --- /dev/null +++ b/tutorial/src/tutorial_widget.cpp @@ -0,0 +1,83 @@ +#include "tutorial_widget.h" + +#include "tutorial_state.h" +#include "tutorial_steps.h" +#include +#include // FindWindowByName + +namespace { + +constexpr float kGuideWidth = 380.0f; +constexpr float kGuideMargin = 20.0f; +constexpr float kButtonWidth = 82.0f; + +// Outlines the step's target panel on the foreground draw list so it sits above +// every regular window. Silently does nothing when the panel is hidden via the +// View menu (FindWindowByName still returns the retained window, but its +// position is stale, hence the WasActive check). +void highlightTarget(TutorialTarget target) { + const char *title = tutorialTargetWindowTitle(target); + if (!title) + return; + + ImGuiWindow *win = ImGui::FindWindowByName(title); + if (!win || !win->WasActive) + return; + + ImVec2 min = win->Pos; + ImVec2 max = ImVec2(win->Pos.x + win->Size.x, win->Pos.y + win->Size.y); + ImGui::GetForegroundDrawList()->AddRect(min, max, IM_COL32(255, 200, 0, 255), 4.0f, 0, 3.0f); +} + +} // namespace + +void TutorialWidget::draw(TutorialState &state) { + if (!state.isActive()) + return; + + const TutorialStep &step = state.currentStep(); + highlightTarget(step.target); + + // Pinned to the bottom-right of the main viewport: the highlight is drawn on + // the foreground list and would otherwise paint over this window. + const ImGuiViewport *vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x - kGuideMargin, + vp->WorkPos.y + vp->WorkSize.y - kGuideMargin), + ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(kGuideWidth, 0.0f), ImGuiCond_Always); + + if (!ImGui::Begin("Tutorial Guide", nullptr, + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoSavedSettings)) { + ImGui::End(); + return; + } + + ImGui::TextDisabled("Step %d of %d", state.stepIndex() + 1, state.stepCount()); + ImGui::Text("%s", step.title); + ImGui::Separator(); + ImGui::TextWrapped("%s", step.instruction); + ImGui::Spacing(); + ImGui::Separator(); + + ImGui::BeginDisabled(state.atFirstStep()); + if (ImGui::Button("Back", ImVec2(kButtonWidth, 0))) + state.back(); + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button(state.atLastStep() ? "Finish" : "Next", ImVec2(kButtonWidth, 0))) + state.next(); + + ImGui::SameLine(); + ImGui::BeginDisabled(state.atLastStep()); + if (ImGui::Button("Skip", ImVec2(kButtonWidth, 0))) + state.skipToLast(); + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Exit", ImVec2(kButtonWidth, 0))) + state.exit(); + + ImGui::End(); +}