diff --git a/src/Application.cpp b/src/Application.cpp index 83791eb0..1b5aacab 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -19,8 +19,8 @@ enum CommandIDs viewMediaClipboard = 0x2001, // Help - welcome = 0x3000, - about = 0x3001 + about = 0x3000, + tutorial = 0x3001 }; StringArray MainComponent::getMenuBarNames() @@ -75,7 +75,7 @@ PopupMenu MainComponent::getMenuForIndex([[maybe_unused]] int menuIndex, const S else if (menuName == "Help") { menu.addCommandItem(&commandManager, CommandIDs::about); - menu.addCommandItem(&commandManager, CommandIDs::welcome); + menu.addCommandItem(&commandManager, CommandIDs::tutorial); } else { @@ -140,7 +140,7 @@ void MainComponent::getAllCommands(Array& commands) commands.addArray(viewIDs, numElementsInArray(viewIDs)); - const CommandID helpIDs[] = { CommandIDs::about, CommandIDs::welcome }; + const CommandID helpIDs[] = { CommandIDs::about, CommandIDs::tutorial }; commands.addArray(helpIDs, numElementsInArray(helpIDs)); } @@ -209,15 +209,15 @@ void MainComponent::getCommandInfo(CommandID commandID, ApplicationCommandInfo& break; /* --Help-- */ - case CommandIDs::welcome: + case CommandIDs::about: result.setInfo( - "Welcome Page", "Provides helpful information for first-time users", "Help", 0); + "About HARP", "Provides helpful information and links for application", "Help", 0); break; - case CommandIDs::about: + case CommandIDs::tutorial: result.setInfo( - "About HARP", "Provides helpful information and links for application", "Help", 0); + "Welcome Tutorial", "Provides helpful information for first-time users", "Help", 0); break; } @@ -279,18 +279,18 @@ bool MainComponent::perform(const InvocationInfo& info) break; /* --Help-- */ - case CommandIDs::welcome: - DBG_AND_LOG("MainComponent::perform: \"welcome\" command invoked."); - // TODO - openWelcomeWindow(); - - break; - case CommandIDs::about: DBG_AND_LOG("MainComponent::perform: \"about\" command invoked."); openAboutWindow(); break; + case CommandIDs::tutorial: + DBG_AND_LOG("MainComponent::perform: \"tutorial\" command invoked."); + openWelcomeWindow(); + + break; + default: return false; } diff --git a/src/Main.cpp b/src/Main.cpp index 976f43b0..961955dc 100644 --- a/src/Main.cpp +++ b/src/Main.cpp @@ -76,38 +76,17 @@ class GuiAppApplication : public JUCEApplication, public FocusChangeListener mainWindow.reset(new HARPWindow(windowTitle)); - bool forceShowWelcome = false; + bool showWelcome = Settings::getBoolValue("view.showWelcomePopup", true); - bool showWelcome = true; - - if (! forceShowWelcome) + if (showWelcome) { - if (Settings::containsKey("view.showWelcomePopup")) + if (auto* mainComp = + dynamic_cast(getMainWindowPtr()->getContentComponent())) { - showWelcome = Settings::getIntValue("view.showWelcomePopup", 1) == 1; + mainComp->openWelcomeWindow(); } } - if (showWelcome) - { - MessageManager::callAsync( - [this]() - { - DialogWindow::LaunchOptions opts; - opts.dialogTitle = "Welcome"; - opts.content.setOwned(new WelcomeWindow( - [this]() - { - if (auto* mainComp = - dynamic_cast(mainWindow->getContentComponent())) - mainComp->openSettingsWindow(); - })); - opts.content->setSize(480, 500); - opts.useNativeTitleBar = true; - opts.launchAsync(); - }); - } - StringArray args; // Split command line arguments at spaces @@ -585,7 +564,7 @@ class GuiAppApplication : public JUCEApplication, public FocusChangeListener debugAndLog( "GuiAppApplication::handleFileOpenChoice: No window currently focused. Importing file to main window."); - windowForFileImport = mainWindow.get(); + windowForFileImport = getMainWindowPtr(); } if (auto* mainComp = diff --git a/src/MainComponent.cpp b/src/MainComponent.cpp index abcd0b83..b7402618 100644 --- a/src/MainComponent.cpp +++ b/src/MainComponent.cpp @@ -1,5 +1,7 @@ #include "MainComponent.h" +#include "windows/WelcomeWindow.h" + JUCE_IMPLEMENT_SINGLETON(HARPLogger) MainComponent::MainComponent() @@ -37,6 +39,50 @@ void MainComponent::paint(Graphics& g) g.fillAll(getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour::windowBackground)); } +void MainComponent::paintOverChildren(Graphics& g) +{ + if (isTutorialActive) + { + auto area = getLocalBounds(); + g.setColour(Colours::black.withAlpha(0.6f)); + + if (tutorialHighlightRect.isEmpty() && tutorialExtraHighlights.empty()) + { + // Full dim if no highlight + g.fillAll(); + } + else + { + // Dim with cutout + Path backgroundPath; + backgroundPath.addRectangle(area.toFloat()); + + Path highlightPath; + if (! tutorialHighlightRect.isEmpty()) + highlightPath.addRoundedRectangle(tutorialHighlightRect.toFloat(), 5.0f); + + // Add extra highlights to the cutout path + for (auto& rect : tutorialExtraHighlights) + { + highlightPath.addRoundedRectangle(rect.toFloat(), 5.0f); + } + + backgroundPath.setUsingNonZeroWinding(false); + backgroundPath.addPath(highlightPath); + + g.fillPath(backgroundPath); + + g.setColour(Colours::white); + g.drawRoundedRectangle(tutorialHighlightRect.toFloat(), 5.0f, 2.0f); + + for (auto& rect : tutorialExtraHighlights) + { + g.drawRoundedRectangle(rect.toFloat(), 5.0f, 2.0f); + } + } + } +} + void MainComponent::resized() { Rectangle fullArea = getLocalBounds(); @@ -75,6 +121,11 @@ void MainComponent::resized() } fullWindow.performLayout(fullArea); + + if (welcomeWindow != nullptr) + { + welcomeWindow->refreshHighlightForCurrentStep(); + } } void MainComponent::updateWindowConstraints() @@ -303,7 +354,208 @@ void MainComponent::openAboutWindow() options.launchAsync(); } -// void MainComponent::openWelcomeWindow() { TODO } +void MainComponent::openWelcomeWindow(bool ensureDefaultModelLoaded) +{ + if (ensureDefaultModelLoaded) + ensureTutorialModelLoaded(); + + if (welcomeWindow != nullptr) + { + welcomeWindow->toFront(true); + return; + } + + Component::SafePointer safeThis(this); + MessageManager::callAsync( + [safeThis]() + { + if (safeThis == nullptr) + return; + + safeThis->welcomeWindow.reset(new WelcomeWindow(safeThis.getComponent())); + safeThis->welcomeWindow->onClose = [safeThis]() + { + if (safeThis != nullptr) + safeThis->welcomeWindow.reset(); + }; + + safeThis->welcomeWindow->setVisible(true); + safeThis->welcomeWindow->toFront(true); + }); +} + +/* --Tutorial-- */ + +void MainComponent::setTutorialActive(bool active) +{ + isTutorialActive = active; + repaint(); +} + +void MainComponent::setTutorialHighlight(Rectangle bounds) +{ + tutorialHighlightRect = bounds; + repaint(); +} + +void MainComponent::setTutorialExtraHighlights(std::vector> bounds) +{ + tutorialExtraHighlights = bounds; + repaint(); +} + +void MainComponent::ensureTutorialModelLoaded() +{ + if (! mainModelTab.isModelLoaded()) + mainModelTab.loadDefaultModel(); +} + +void MainComponent::resetTutorialAutoLoadedModel() +{ + if (! mainModelTab.isModelLoaded()) + return; + + if (mainModelTab.getLoadedPath() == TutorialConstants::fallbackModelPath) + { + mainModelTab.resetState(); + } +} + +Rectangle MainComponent::getModelSelectBounds() +{ + auto bounds = mainModelTab.getModelSelectBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getControlsBounds() +{ + auto bounds = mainModelTab.getControlsBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getInputTrackBounds() +{ + auto bounds = mainModelTab.getInputTrackBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getInputFolderBounds() +{ + auto bounds = mainModelTab.getInputFolderBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getInputPlayBounds() +{ + auto bounds = mainModelTab.getInputPlayBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getProcessButtonBounds() +{ + auto bounds = mainModelTab.getProcessButtonBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getTracksBounds() +{ + auto bounds = mainModelTab.getTracksBounds(); + return getLocalArea(&mainModelTab, bounds); +} + +Rectangle MainComponent::getClipboardBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + return mediaClipboardWidget.getBounds(); + return {}; +} + +Rectangle MainComponent::getClipboardTrackAreaBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getClipboardTrackAreaBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardControlsBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getClipboardControlsBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardNameBoxBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getClipboardNameBoxBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardButtonsBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getClipboardButtonsBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardAddButtonBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getAddFileButtonBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardRemoveButtonBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getRemoveButtonBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardPlayButtonBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getPlayButtonBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getClipboardSendToDAWButtonBounds() +{ + if (showMediaClipboard && mediaClipboardWidget.isVisible()) + { + auto bounds = mediaClipboardWidget.getSendToDAWButtonBounds(); + return getLocalArea(&mediaClipboardWidget, bounds); + } + return {}; +} + +Rectangle MainComponent::getInfoBarBounds() +{ + if (showStatusArea && statusAreaWidget.isVisible()) + return statusAreaWidget.getBounds(); + return {}; +} /* --Miscellaneous-- */ diff --git a/src/MainComponent.h b/src/MainComponent.h index 42f5bc77..2acb5841 100644 --- a/src/MainComponent.h +++ b/src/MainComponent.h @@ -21,9 +21,13 @@ #include "utils/Interface.h" #include "utils/Logging.h" #include "utils/Settings.h" +#include "utils/Tutorial.h" using namespace juce; +// Forward declaration (include in .cpp) +class WelcomeWindow; + class MainComponent : public Component, public MenuBarModel, public ApplicationCommandTarget, @@ -66,11 +70,41 @@ class MainComponent : public Component, // Help void openAboutWindow(); - //void openWelcomeWindow(); + void openWelcomeWindow(bool ensureDefaultModelLoaded = false); + + /* Tutorial */ + + ModelTab* getModelTab() { return &mainModelTab; } + + void setTutorialActive(bool active); + void setTutorialHighlight(Rectangle bounds); + void setTutorialExtraHighlights(std::vector> bounds); + void ensureTutorialModelLoaded(); + void resetTutorialAutoLoadedModel(); + + // Bounds accessors for tutorial steps (public for WelcomeWindow) + Rectangle getModelSelectBounds(); + Rectangle getControlsBounds(); + Rectangle getInputTrackBounds(); + Rectangle getInputFolderBounds(); + Rectangle getInputPlayBounds(); + Rectangle getProcessButtonBounds(); + Rectangle getTracksBounds(); + Rectangle getClipboardBounds(); + Rectangle getClipboardTrackAreaBounds(); + Rectangle getClipboardControlsBounds(); + Rectangle getClipboardNameBoxBounds(); + Rectangle getClipboardButtonsBounds(); + Rectangle getClipboardAddButtonBounds(); + Rectangle getClipboardRemoveButtonBounds(); + Rectangle getClipboardPlayButtonBounds(); + Rectangle getClipboardSendToDAWButtonBounds(); + Rectangle getInfoBarBounds(); /* Component */ void paint(Graphics& g) override; + void paintOverChildren(Graphics& g) override; void resized() override; void updateWindowConstraints(); @@ -120,6 +154,11 @@ class MainComponent : public Component, StatusAreaWidget statusAreaWidget; MediaClipboardWidget mediaClipboardWidget; + bool isTutorialActive = false; + Rectangle tutorialHighlightRect; + std::vector> tutorialExtraHighlights; + std::unique_ptr welcomeWindow; + SharedResourcePointer sharedTokens; SharedResourcePointer statusMessage; diff --git a/src/ModelTab.h b/src/ModelTab.h index 00cd713a..e6e8e1d0 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -17,6 +17,7 @@ #include "utils/Errors.h" #include "utils/Logging.h" +#include "utils/Tutorial.h" using namespace juce; @@ -48,6 +49,66 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas ~ModelTab() { modelSelectionWidget.removeChangeListener(this); } + // Accessor methods for WelcomeWindow tutorial + std::shared_ptr getModel() const { return model; } + String getLoadedPath() const { return model->getLoadedPath(); } + + void loadDefaultModel() + { + modelSelectionWidget.loadModelBypass(TutorialConstants::fallbackModelPath); + } + + // Bounds accessors for tutorial steps + Rectangle getModelSelectBounds() const + { + return modelSelectionWidget.getBounds().expanded(2, 2); + } + + Rectangle getControlsBounds() const + { + auto bounds = controlAreaWidget.getBounds(); + + if (bounds.getWidth() > 0 && bounds.getHeight() > 0) + return bounds.expanded(2, 2); + + return {}; + } + + Rectangle getInputFolderBounds() + { + auto bounds = inputTrackAreaWidget.getFirstTrackFolderButtonBounds(); + return getLocalArea(&inputTrackAreaWidget, bounds); + } + + Rectangle getInputPlayBounds() + { + auto bounds = inputTrackAreaWidget.getFirstTrackPlayButtonBounds(); + return getLocalArea(&inputTrackAreaWidget, bounds); + } + + Rectangle getInputTrackBounds() const { return inputTrackAreaWidget.getBounds(); } + + Rectangle getProcessButtonBounds() const { return processCancelButton.getBounds(); } + + Rectangle getTracksBounds() const + { + auto bounds = inputTrackAreaWidget.getBounds(); + if (outputTrackAreaWidget.isVisible()) + bounds = bounds.getUnion(outputTrackAreaWidget.getBounds()); + + if (inputTracksLabel.isVisible()) + bounds = bounds.getUnion(inputTracksLabel.getBounds()); + if (outputTracksLabel.isVisible()) + bounds = bounds.getUnion(outputTracksLabel.getBounds()); + + return bounds.expanded(2, 2); + } + + bool isModelLoaded() + { + return model->isLoaded(); + } + void resized() override { FlexBox tabArea; @@ -170,7 +231,7 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas void resetState() { - model.reset(); + model = std::make_shared(); modelSelectionWidget.resetState(); modelInfoWidget.resetState(); @@ -182,6 +243,8 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas processCancelButton.setEnabled(false); currentProcessID = 0; + + resized(); } private: @@ -373,9 +436,10 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas inputTrackAreaWidget.updateTracks(model->getInputTracks()); outputTrackAreaWidget.updateTracks(model->getOutputTracks()); - sendSynchronousChangeMessage(); resized(); + sendSynchronousChangeMessage(); + // Re-enable processing immediately processCancelButton.setEnabled(true); } diff --git a/src/media/MediaDisplayComponent.cpp b/src/media/MediaDisplayComponent.cpp index 1aff5383..111a311c 100644 --- a/src/media/MediaDisplayComponent.cpp +++ b/src/media/MediaDisplayComponent.cpp @@ -1014,6 +1014,26 @@ void MediaDisplayComponent::updateCursorPosition() Rectangle(cursorPositionX, cursorPositionY, cursorWidth, mediaBounds.getHeight())); } +Rectangle MediaDisplayComponent::getChooseFileButtonBounds() +{ + if (auto* p = chooseFileButton.getParentComponent()) + { + return getLocalArea(p, chooseFileButton.getBounds()); + } + + return chooseFileButton.getBounds(); +} + +Rectangle MediaDisplayComponent::getPlayButtonBounds() +{ + if (auto* p = playStopButton.getParentComponent()) + { + return getLocalArea(p, playStopButton.getBounds()); + } + + return playStopButton.getBounds(); +} + void MediaDisplayComponent::mouseEnter(const MouseEvent& e) { if (! isThumbnailTrack() && e.eventComponent == getMediaComponent() diff --git a/src/media/MediaDisplayComponent.h b/src/media/MediaDisplayComponent.h index 11b2a336..312d9410 100644 --- a/src/media/MediaDisplayComponent.h +++ b/src/media/MediaDisplayComponent.h @@ -126,6 +126,9 @@ class MediaDisplayComponent : public Component, virtual bool isPlaying() { return transportSource.isPlaying(); } + Rectangle getChooseFileButtonBounds(); + Rectangle getPlayButtonBounds(); + int getNumOverheadLabels(); void addOverheadLabel(OverheadLabelComponent* l); diff --git a/src/utils/Tutorial.h b/src/utils/Tutorial.h new file mode 100644 index 00000000..835a0593 --- /dev/null +++ b/src/utils/Tutorial.h @@ -0,0 +1,12 @@ +/** + * @file Tutorial.h + * @brief Constants needed for tutorial steps. + * @author saumya-pailwan + */ + +#pragma once + +namespace TutorialConstants +{ +inline constexpr const char* fallbackModelPath = "teamup-tech/demucs-source-separation"; +} diff --git a/src/widgets/MediaClipboardWidget.h b/src/widgets/MediaClipboardWidget.h index 26266987..3753cbfd 100644 --- a/src/widgets/MediaClipboardWidget.h +++ b/src/widgets/MediaClipboardWidget.h @@ -131,6 +131,40 @@ class MediaClipboardWidget : public Component, public ChangeListener trackAreaWidget.addTrackFromFilePath(filePath, fromDAW); } + Rectangle getClipboardTrackAreaBounds() const { return trackArea.getBounds().expanded(2); } + + Rectangle getClipboardControlsBounds() const { return controlsComponent.getBounds().expanded(2); } + + Rectangle getClipboardNameBoxBounds() const + { + return getLocalArea(&controlsComponent, selectionTextBox.getBounds()).expanded(2); + } + + Rectangle getClipboardButtonsBounds() const + { + return getLocalArea(&controlsComponent, buttonsComponent.getBounds()).expanded(2); + } + + Rectangle getAddFileButtonBounds() const + { + return getLocalArea(&buttonsComponent, addFileButton.getBounds()).expanded(2); + } + + Rectangle getRemoveButtonBounds() const + { + return getLocalArea(&buttonsComponent, removeSelectionButton.getBounds()).expanded(2); + } + + Rectangle getPlayButtonBounds() const + { + return getLocalArea(&buttonsComponent, playStopButton.getBounds()).expanded(2); + } + + Rectangle getSendToDAWButtonBounds() const + { + return getLocalArea(&buttonsComponent, sendToDAWButton.getBounds()).expanded(2); + } + void addFileCallback() { StringArray validExtensions = MediaDisplayComponent::getSupportedExtensions(); diff --git a/src/widgets/ModelSelectionWidget.h b/src/widgets/ModelSelectionWidget.h index b657b489..bba39217 100644 --- a/src/widgets/ModelSelectionWidget.h +++ b/src/widgets/ModelSelectionWidget.h @@ -210,6 +210,12 @@ class ModelSelectionWidget : public Component, public ChangeBroadcaster, public String getCurrentlySelectedPath() { return selectedPath; } + void loadModelBypass(const String& modelPath) + { + selectedPath = modelPath; + sendChangeMessage(); + } + void resetState() { lastLoadedPathIndex = -1; @@ -483,8 +489,7 @@ class ModelSelectionWidget : public Component, public ChangeBroadcaster, public DBG_AND_LOG("ModelSelectionWidget::openCustomPathPopup::loadCallback: " << "Custom path \"" << path << "\" entered."); - selectedPath = path; - sendChangeMessage(); + loadModelBypass(path); }; std::function cancelCallback = [this]() diff --git a/src/widgets/TrackAreaWidget.h b/src/widgets/TrackAreaWidget.h index 23767fed..0fcd8c27 100644 --- a/src/widgets/TrackAreaWidget.h +++ b/src/widgets/TrackAreaWidget.h @@ -115,6 +115,34 @@ class TrackAreaWidget : public Component, return nullptr; } + Rectangle getFirstTrackFolderButtonBounds() + { + if (mediaDisplays.size() > 0) + { + auto display = mediaDisplays[0].get(); + auto bounds = display->getChooseFileButtonBounds(); + + // Convert to TrackAreaWidget coordinates + return getLocalArea(display, bounds); + } + + return {}; + } + + Rectangle getFirstTrackPlayButtonBounds() + { + if (mediaDisplays.size() > 0) + { + auto display = mediaDisplays[0].get(); + auto bounds = display->getPlayButtonBounds(); + + // Convert to TrackAreaWidget coordinates + return getLocalArea(display, bounds); + } + + return {}; + } + std::vector getDAWLinkedDisplays() { std::vector linkedDisplays; diff --git a/src/windows/WelcomeWindow.h b/src/windows/WelcomeWindow.h index 1edc967b..76ce15d6 100644 --- a/src/windows/WelcomeWindow.h +++ b/src/windows/WelcomeWindow.h @@ -6,124 +6,766 @@ #pragma once +#include +#include + #include #include "../utils/Settings.h" +#include "../utils/Tutorial.h" using namespace juce; -class WelcomeWindow : public Component +// Forward declaration for TutorialStep +class MainComponent; + +struct TutorialStep +{ + String title; + String description; + std::function(MainComponent*)> getHighlightBounds; + std::function>(MainComponent*)> getExtraHighlights = nullptr; +}; + +class WelcomeWindow : public DocumentWindow, public ChangeListener { public: - WelcomeWindow(std::function openSettingsCallback) - : onOpenSettings(std::move(openSettingsCallback)) - { - setSize(480, 500); - setColour(ResizableWindow::backgroundColourId, Colours::darkgrey); - - // --- Intro Text --- - introText.setText("Welcome to HARP!\n" - "A tool for hosted, asynchronous, remote processing of audio tracks.", - dontSendNotification); - introText.setJustificationType(Justification::centred); - introText.setFont(Font(17.0f, Font::bold)); - addAndMakeVisible(introText); - - // --- Instructions --- - instructions.setText( - "\n HARP operates as a standalone app or plugin-like editor within your DAW, allowing you " - "to process tracks using machine learning models hosted on platforms like Hugging Face or Stability AI.\n\n" - "The interface is organized into several sections:\n" - "1. The top area allows model selection and parameter control.\n" - "2. The middle area handles audio & MIDI I/O and processing.\n" - "3. The bottom shows model status & info for hovered component.\n" - "4. The rightmost panel can hold intermediate inputs / outputs and overwrite DAW-linked tracks.\n\n" - "Getting started:\n" - "Access tokens are required for models hosted on Hugging Face (as ZeroGPU spaces)" - " or Stability AI, to authenticate your account and let HARP securely fetch and run models. " - "The first two models in the dropdown are hosted by Stability AI, and the rest are hosted on Hugging Face.\n\n" - "Add tokens under File -> Settings -> Hugging Face, or by clicking 'Open Settings' below.", - dontSendNotification); - instructions.setJustificationType(Justification::centredTop); - instructions.setFont(Font(14.0f)); - addAndMakeVisible(instructions); - - // --- Buttons --- - openSettingsButton.setButtonText("Open Settings"); - openSettingsButton.onClick = [this]() - { - if (onOpenSettings) - onOpenSettings(); - }; - addAndMakeVisible(openSettingsButton); - - // --- Checkbox --- - dontShowAgain.setButtonText("Don't show this again"); - dontShowAgain.setColour(ToggleButton::textColourId, Colours::white); - addAndMakeVisible(dontShowAgain); - - continueButton.setButtonText("Continue"); - continueButton.onClick = [this]() - { - const bool dontShow = dontShowAgain.getToggleState(); - Settings::setValue("view.showWelcomePopup", dontShow ? 0 : 1, true); - - if (auto* window = findParentComponentOfClass()) - window->closeButtonPressed(); - }; - addAndMakeVisible(continueButton); - - // --- View Documentation --- - docsLink.setButtonText("View Documentation"); - docsLink.setURL(URL("https://harp-plugin.netlify.app/content/intro.html")); - docsLink.setColour(HyperlinkButton::textColourId, Colours::skyblue); - addAndMakeVisible(docsLink); - - // --- Footer --- - footerLabel.setText("Copyright 2026 TEAMuP. All rights reserved.", dontSendNotification); - footerLabel.setJustificationType(Justification::centred); - footerLabel.setFont(Font(13.0f)); - addAndMakeVisible(footerLabel); + WelcomeWindow(MainComponent* mainComp) + : DocumentWindow("Welcome to HARP", + Desktop::getInstance().getDefaultLookAndFeel().findColour( + ResizableWindow::backgroundColourId), + DocumentWindow::allButtons), + mainComponent(mainComp) + { + setUsingNativeTitleBar(true); + WelcomeContent* c = new WelcomeContent(*this); + setContentOwned(c, true); + content = c; + + setAlwaysOnTop(true); + setResizable(true, true); // Still need to set this to fix size with constrainer + + setConstrainer(&constrainer); + constrainer.setMinimumSize(content->getWidth(), content->getHeight()); + constrainer.setMaximumSize(content->getWidth(), content->getHeight()); + constrainer.setMinimumOnscreenAmounts(40, 40, 40, 40); + + positionOnMainComponentDisplay(); + + if (mainComponent) + { + mainComponent->getModelTab()->addChangeListener(this); + mainComponent->setTutorialActive(true); + } + + rebuildSteps(); + updateStep(); + } + + ~WelcomeWindow() override + { + if (mainComponent) + { + mainComponent->getModelTab()->removeChangeListener(this); + mainComponent->setTutorialActive(false); + } + } + + void changeListenerCallback(ChangeBroadcaster*) override + { + // Model loaded/changed + if (pendingTutorialFallbackLoad) + { + if (mainComponent != nullptr) + { + auto model = mainComponent->getModelTab()->getModel(); + auto loadedPath = model ? model->getLoadedPath() : String(); + + autoLoadedByTutorialFallback = (loadedPath == TutorialConstants::fallbackModelPath); + } + pendingTutorialFallbackLoad = false; + } + else if (autoLoadedByTutorialFallback && mainComponent != nullptr) + { + auto model = mainComponent->getModelTab()->getModel(); + auto loadedPath = model ? model->getLoadedPath() : String(); + if (loadedPath != TutorialConstants::fallbackModelPath) + autoLoadedByTutorialFallback = false; + } + + rebuildSteps(); + + if (content->currentStep > 0) + { + // Auto-jump to Step 3 ("Quick Start") if user loads a new model + // (Index 2 corresponds to "Quick Start") + if (steps.size() > 2) + { + content->currentStep = 2; + } + } + + updateStep(); + } + + void refreshHighlightForCurrentStep() { updateStep(); } + + void rebuildSteps() + { + steps.clear(); + + // 1. Static Intro + steps.push_back( + { "Welcome to HARP", + "HARP is your hub for hosted, asynchronous, remote audio processing.\n\n" + "HARP operates as a standalone app or plugin-like editor within your DAW, allowing you to process tracks using ML models hosted on platforms like Hugging Face or Stability AI.\n\n" + "This quick tutorial will guide you through the main features of the application. Click 'Learn more' for additional online documentation.", + [](MainComponent*) { return Rectangle(); } }); + + // 2. Select Model + steps.push_back( + { "Select a Model", + "Select a model from the dropdown menu at the top and click Load.\n\n" + "Once loaded, the model's details and controls will appear below.\n\n" + "If you click Next without loading a model, HARP loads Demucs for guidance.", + [](MainComponent* c) { return c->getModelSelectBounds(); } }); + + String modelName = "current model"; + + if (mainComponent) + { + auto model = mainComponent->getModelTab()->getModel(); + if (model && model->isLoaded()) + { + modelName = model->getMetadata().name; + } + } + + // 3. Quick Start (Dynamic) + String stepTitle = "Quick Start"; + + steps.push_back( + { stepTitle, + "This is the " + modelName + ".\n" + getFriendlyModelSummary(modelName) + + "\n\n" + "1. Add input\n" + "Drag an audio file into the Input track, use the folder icon to browse, or the play button to preview.\n\n" + "2. Process\n" + "Click Process to run the model.\n\n" + "3. Output\n" + "Processed tracks appear in the Output section, where you can play or save them.", + [](MainComponent* c) + { + auto bounds = c->getLocalBounds(); + auto clipboard = c->getClipboardBounds(); + if (! clipboard.isEmpty()) + { + // Exclude clipboard (assume it's on the right) + bounds.setRight(clipboard.getX()); + } + return bounds; + }, + // Highlight entire interface except clipboard + [](MainComponent* c) + { + std::vector> v; + v.push_back(c->getInputFolderBounds()); + v.push_back(c->getInputPlayBounds()); + v.push_back(c->getProcessButtonBounds()); + return v; + } }); + + // 4. Configure Parameters (Dynamic) + if (mainComponent) + { + auto model = mainComponent->getModelTab()->getModel(); + if (model && model->isLoaded()) + { + String controlsStepTitle = "Configure Parameters (Optional)"; + String baseText = + "Some models will have controls to customize model behavior. Typical controls include ones that balance quality vs speed, allow selection of model variants, or provide advanced options for experienced users.\n\n" + "To see descriptions of what controls do for a model Click on the button below. Do this now to see the control descriptions for " + + modelName + ".\n\n"; + + String fullText = baseText; + + if (content->showingDetails) + { + fullText += + "--------------------------------------------------\nDetailed Control Descriptions:\n\n"; + auto controls = model->getControls(); + if (controls.empty()) + { + fullText += "- No adjustable controls for this model."; + } + else + { + int index = 1; + String modelNameForCtrl = model->getMetadata().name; + + // First pass: All except Model + for (const auto& info : controls) + { + if (String(info->label).equalsIgnoreCase("Model")) + continue; + + String friendlyCtrlDesc = getFriendlyControlDescription( + modelNameForCtrl, info->label, info->info); + + fullText += String(index++) + ". " + info->label + ": " + + friendlyCtrlDesc + "\n\n"; + } + + // Second pass: Only Model + for (const auto& info : controls) + { + if (! String(info->label).equalsIgnoreCase("Model")) + continue; + + String friendlyCtrlDesc = getFriendlyControlDescription( + modelNameForCtrl, info->label, info->info); + + fullText += String(index++) + ". " + info->label + ": " + + friendlyCtrlDesc + "\n\n"; + } + fullText += + "Tip: We suggest sticking with the default parameters for the best balance of processing time vs. quality."; + } + } + + steps.push_back({ controlsStepTitle, + fullText, + [](MainComponent* c) { return c->getControlsBounds(); } }); + } + } + + // 5. Manage Tracks + steps.push_back( + { "Manage Tracks", + "The highlighted panel contains your input and output tracks.\n\n" + "You can drag and drop audio/MIDI files here, or click on the folder icon in the input audio section to choose from a local folder.\n\n" + "Processed results appear in the output tracks section.\n\n" + "Please note that these tracks interact with the model.", + [](MainComponent* c) { return c->getTracksBounds(); }, + nullptr }); + + steps.push_back( + { "Track Status", + "The bottom bar shows the connection and processing status.\n\n" + "LOADED: The model is ready to use.\n\n" + "PROCESSING: The model is busy working. Please wait.\n\n" + "ERROR: Something went wrong. Check the description below it and try reloading the model.", + [](MainComponent* c) { return c->getInfoBarBounds(); }, + nullptr }); + + steps.push_back( + { "Media Clipboard", + "This clipboard is a scratch space and does not feed model inputs directly.\n\n" + "You can add tracks with the folder icon in the clipboard toolbar, rename a selected track in the text box above the list, remove, play, or save a selected entry, and send selected tracks to your DAW with the send icon.\n\n" + "Use it to stash useful outputs and reuse them across model runs.", + [](MainComponent* c) { return c->getClipboardBounds(); }, + [](MainComponent* c) + { + std::vector> v; + v.push_back(c->getClipboardControlsBounds()); + return v; + } }); + + // 8. Interface Summary + steps.push_back( + { "Interface Summary", + "Top Bar: Select a model from the dropdown and click Load to initialize it.\n\n" + "Left Panel: This is where your Input and Output tracks live. Models read from and write to these tracks.\n\n" + "Right Panel: A scratch pad (Clipboard) to stash tracks you want to save or reuse later.", + [](MainComponent*) { return Rectangle(); }, + [](MainComponent* c) + { + std::vector> v; + v.push_back(c->getModelSelectBounds()); + v.push_back(c->getInputTrackBounds()); + v.push_back(c->getClipboardBounds()); + return v; + } }); + + steps.push_back( + { "All Set!", + "Most models in HARP are hosted on external service like Hugging Face and Stability AI. To use them, you will need API tokens.\n\n" + "An API token is a private access key that lets HARP securely connect to these services on your behalf. It tells the service that you are allowed to use the model.\n\n" + "You can get these tokens from your account settings on the respective service websites. You can find the exact link in 'File -> Settings' and select the tab of the service whose tokens you need to use.\n\n" + "You are now ready to start creating!", + [](MainComponent*) { return Rectangle(); } }); + + // Refresh if we are showing the tutorial + if (isVisible()) + { + if (content->currentStep >= (int) steps.size()) + content->currentStep = (int) steps.size() - 1; + updateStep(); + } + } + + String getFriendlyModelSummary(const String& name) + { + if (name.containsIgnoreCase("demucs")) + { + return "It separates music audio into drums, bass, vocals, and 'other' stems."; + } + if (name.containsIgnoreCase("megatts") || name.containsIgnoreCase("voice")) + { + return "It generates realistic speech or clones voices from reference audio."; + } + if (name.containsIgnoreCase("musicgen") || name.containsIgnoreCase("audioldm")) + { + return "It generates new music or audio based on text descriptions."; + } + if (name.containsIgnoreCase("stability") || name.containsIgnoreCase("stable audio")) + { + return "It generates or transforms audio using state-of-the-art diffusion models."; + } + + return "It is an advanced audio processing model hosted in the cloud."; } - void paint(Graphics& g) override { g.fillAll(findColour(ResizableWindow::backgroundColourId)); } + String getFriendlyModelOverview(const String& name) + { + // DEMUCS (Default or Selected) + if (name.containsIgnoreCase("demucs")) + { + return "About:\n" + "The Demucs model is a state-of-the-art music source separation model. " + "It separates a stereo specific mixture into four distinct tracks: Drums, Bass, Vocals, and Other instruments.\n\n" + "Inputs:\n" + "A generic audio file (mp3, wav, etc.) containing a song or music mix.\n\n" + "Outputs:\n" + "Four separate audio tracks corresponding to the separated stems."; + } + + // MEGATTS / Voice + if (name.containsIgnoreCase("megatts") || name.containsIgnoreCase("voice")) + { + return "About:\n" + "This model generates realistic speech or clones voices from reference audio.\n\n" + "Inputs:\n" + "Reference audio of the voice to clone and the text you want it to speak.\n\n" + "Outputs:\n" + "A generated audio clip of the spoken text."; + } + + // MUSICGEN / Audio Generation + if (name.containsIgnoreCase("musicgen") || name.containsIgnoreCase("audioldm")) + { + return "About:\n" + "Generates new music or audio based on text descriptions.\n\n" + "Inputs:\n" + "A text prompt describing the music (e.g., 'lo-fi beat with piano').\n\n" + "Outputs:\n" + "A generated audio track matching the description."; + } - void resized() override + // Fallback + return "About:\n" + "This is an advanced audio processing model hosted in the cloud.\n\n" + "Inputs:\n" + "Refer to the specific model documentation or parameters for input requirements.\n\n" + "Outputs:\n" + "Processed audio or MIDI results."; + } + + String getFriendlyModelDescription(const String& /*name*/, const String& rawDesc) { - // Get the content area dimensions - auto area = getLocalBounds(); + // Unused but kept for API consistency if needed later + return rawDesc; + } - // Define content width (same as original 480 width minus padding) - const int contentWidth = 440; - const int buttonWidth = 200; + String getFriendlyControlDescription(const String& modelName, + const String& label, + const String& rawInfo) + { + // DEMUCS + if (modelName.containsIgnoreCase("demucs")) + { + if (label.equalsIgnoreCase("shifts")) + { + // Roman numerals for sub-points + return "Determines how many times the audio is shifted and processed.\n" + " i. Increasing this value can improve quality but will take longer to process.\n" + " ii. A value of 0 or 1 is usually sufficient for good results."; + } + if (label.equalsIgnoreCase("overlap")) + { + return "Controls the smoothness between processed audio chunks. The default value is optimized for a seamless sound."; + } + } - // Calculate horizontal center offset - const int centerX = (area.getWidth() - contentWidth) / 2; - const int buttonX = (area.getWidth() - buttonWidth) / 2; + // STABILITY AI (Text-to-Audio / Audio-to-Audio) + if (modelName.containsIgnoreCase("stability") + || modelName.containsIgnoreCase("text-to-audio") + || modelName.containsIgnoreCase("audio-to-audio") + || modelName.containsIgnoreCase("stable audio")) + { + if (label.containsIgnoreCase("Duration")) + return "Sets the length of the generated audio in seconds."; + + if (label.containsIgnoreCase("steps")) + return "Number of diffusion steps. Higher values improve quality but take longer to process."; + + if (label.containsIgnoreCase("cfg")) + return "Classifier Free Guidance. Higher values make the output adhere more strictly to the prompt."; + + if (label.containsIgnoreCase("Output Format")) + return "The file format for the generated audio (e.g. wav)."; + + if (label.containsIgnoreCase("Text Prompt")) + return "Description of the audio content you want to generate."; + } - // Calculate vertical center offset for the entire content block - const int totalContentHeight = 485; // Approximate total height of all content - const int startY = jmax(0, (area.getHeight() - totalContentHeight) / 2); + // MEGATTS 3 + if (modelName.containsIgnoreCase("megatts")) + { + if (label.containsIgnoreCase("timesteps")) + { + return "Controls the quality of the voice generation.\n" + " i. Higher values (e.g. 50+) result in clearer, higher-quality audio but take longer to generate."; + } + if (label.containsIgnoreCase("intelligibility")) + { + return "Ensures the generated speech is clear and easy to understand.\n" + " i. Increase this value if the output sounds mumbled or unclear."; + } + if (label.containsIgnoreCase("similarity")) + { + return "Controls how closely the cloned voice mimics the reference audio.\n" + " i. Increase this for a stronger likeness to the original voice."; + } + if (label.containsIgnoreCase("text")) + { + return "Enter the text you want the cloned voice to speak."; + } + } + + // TRIA + if (modelName.containsIgnoreCase("tria")) + { + if (label.equalsIgnoreCase("Model")) + { + return "Choose which variant to you want to use. Currently this is the only one available, however more models would be available soon."; + } + } - // Position all elements relative to center - introText.setBounds(centerX, startY + 15, contentWidth, 50); - instructions.setBounds(centerX + 10, startY + 70, contentWidth - 20, 270); - openSettingsButton.setBounds(buttonX, startY + 355, buttonWidth, 30); - dontShowAgain.setBounds(buttonX, startY + 390, buttonWidth, 24); - continueButton.setBounds(buttonX, startY + 420, buttonWidth, 30); - docsLink.setBounds(buttonX, startY + 455, buttonWidth, 20); - footerLabel.setBounds(centerX - 20, startY + 475, contentWidth + 40, 20); + // GENERIC FALLBACK + if (rawInfo.isNotEmpty()) + return rawInfo; + + return "Controls this specific parameter of the model."; + } + + void closeButtonPressed() override + { + // Reset state before closing + if (mainComponent) + { + mainComponent->setTutorialHighlight({}); + mainComponent->setTutorialActive(false); + } + + if (onClose) + onClose(); + } + + std::function onClose; + + void paint(Graphics& g) override + { + g.fillAll(getLookAndFeel().findColour(ResizableWindow::backgroundColourId)); } private: - std::function onOpenSettings; - Label introText; - Label instructions; - TextButton openSettingsButton; - ToggleButton dontShowAgain; - TextButton continueButton; - HyperlinkButton docsLink; - Label footerLabel; + void positionOnMainComponentDisplay() + { + const int width = content != nullptr ? content->getWidth() : getWidth(); + const int height = content != nullptr ? content->getHeight() : getHeight(); + setSize(width, height); + + Rectangle targetBounds; + + if (mainComponent != nullptr) + { + if (auto* topLevel = mainComponent->getTopLevelComponent()) + targetBounds = topLevel->getScreenBounds(); + } + + if (targetBounds.isEmpty()) + { + centreWithSize(width, height); + return; + } + + setTopLeftPosition(targetBounds.getCentreX() - width / 2, + targetBounds.getCentreY() - height / 2); + } + + class WelcomeContent : public Component + { + public: + explicit WelcomeContent(WelcomeWindow& ownerRef) : owner(ownerRef) + { + // UI Init + addAndMakeVisible(titleLabel); + titleLabel.setFont(Font(24.0f, Font::bold)); + titleLabel.setJustificationType(Justification::centred); + + addAndMakeVisible(descriptionEditor); + descriptionEditor.setMultiLine(true); + descriptionEditor.setReadOnly(true); + descriptionEditor.setScrollbarsShown(true); + descriptionEditor.setCaretVisible(false); + // Make it look like a label (transparent) + descriptionEditor.setColour(TextEditor::backgroundColourId, Colours::transparentBlack); + descriptionEditor.setColour(TextEditor::outlineColourId, Colours::transparentBlack); + descriptionEditor.setFont(Font(16.0f)); + + addAndMakeVisible(learnMoreLink); + learnMoreLink.setButtonText("Learn more"); + learnMoreLink.setURL(URL("https://harp-plugin.netlify.app/content/intro.html")); + + addAndMakeVisible(copyrightLabel); + copyrightLabel.setText("Copyright 2026 TEAMuP. All rights reserved.", + dontSendNotification); + copyrightLabel.setJustificationType(Justification::centred); + copyrightLabel.setFont(Font(12.0f)); + copyrightLabel.setColour(Label::textColourId, Colours::grey); + + addAndMakeVisible(nextButton); + nextButton.onClick = [this] { owner.nextStep(); }; + + addAndMakeVisible(prevButton); + prevButton.onClick = [this] { owner.prevStep(); }; + + addAndMakeVisible(skipButton); + skipButton.onClick = [this] { owner.skipTutorial(); }; + + addAndMakeVisible(dontShowAgainToggle); + bool notToggleState = Settings::getBoolValue("view.showWelcomePopup", true); + dontShowAgainToggle.setToggleState(! notToggleState, dontSendNotification); + + addAndMakeVisible(pageIndicator); + pageIndicator.setJustificationType(Justification::centredLeft); + pageIndicator.setFont(Font(12.0f)); + pageIndicator.setInterceptsMouseClicks(false, false); + + addAndMakeVisible(showDetailsButton); + showDetailsButton.onClick = [this] + { + showingDetails = ! showingDetails; + showDetailsButton.setButtonText(showingDetails + ? "Hide detailed control descriptions" + : "Show detailed control descriptions"); + owner.rebuildSteps(); + owner.updateStep(); + }; + + setSize(500, 420); + } + + void resized() override + { + auto area = getLocalBounds().reduced(20); + + titleLabel.setBounds(area.removeFromTop(40)); + area.removeFromTop(10); + + auto footerArea = area.removeFromBottom(72); + auto footer = footerArea.removeFromTop(30); + auto toggleRow = footerArea.removeFromBottom(30); + + const int buttonHeight = 30; + const int buttonPaddingX = 28; + const int buttonWidth = + jmax(skipButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX, + nextButton.getBestWidthForHeight(buttonHeight) + buttonPaddingX); + skipButton.setBounds(footer.removeFromLeft(buttonWidth)); + nextButton.setBounds(footer.removeFromRight(buttonWidth)); + footer.removeFromRight(10); + prevButton.setBounds(footer.removeFromRight(buttonWidth)); + + const int middleRightX = + prevButton.isVisible() ? (prevButton.getX() - 10) : nextButton.getX(); + const int middleX = skipButton.getRight() + 12; + const int middleW = jmax(0, middleRightX - middleX); + copyrightLabel.setBounds(middleX, footer.getY(), middleW, footer.getHeight()); + + constexpr int toggleHeight = 26; + const int toggleTextWidth = + Font(16.0f).getStringWidth(dontShowAgainToggle.getButtonText()); + const int toggleWidth = 24 + toggleTextWidth + 16; + const int toggleY = toggleRow.getY() + 12; + const int toggleX = toggleRow.getRight() - toggleWidth; + dontShowAgainToggle.setBounds(toggleX, toggleY, toggleWidth, toggleHeight); + + const int stepWidth = + jmax(skipButton.getWidth(), + pageIndicator.getFont().getStringWidth(pageIndicator.getText()) + 16); + pageIndicator.setBounds(toggleRow.getX(), toggleY, stepWidth, 26); + + area.removeFromBottom(8); + + if (currentStep == 0) + { + auto learnMoreArea = area.removeFromBottom(36); + learnMoreLink.setBounds(learnMoreArea.withSizeKeepingCentre(180, 30)); + area.removeFromBottom(4); + descriptionEditor.setBounds(area); + } + else + { + if (showDetailsButton.isVisible()) + { + auto btnArea = area.removeFromBottom(30); + showDetailsButton.setBounds(btnArea.reduced(20, 0)); + area.removeFromBottom(8); + } + + descriptionEditor.setBounds(area); + learnMoreLink.setVisible(false); + } + } + + Label titleLabel; + TextEditor descriptionEditor; + + TextButton nextButton { "Next" }; + TextButton prevButton { "Back" }; + TextButton skipButton { "Skip Tutorial" }; + ToggleButton dontShowAgainToggle { "Don't show again" }; + + Label pageIndicator; + HyperlinkButton learnMoreLink { "Learn more", + URL("https://harp-plugin.netlify.app/content/intro.html") }; + Label copyrightLabel; + + TextButton showDetailsButton { "Show detailed control descriptions" }; + + int currentStep = 0; + bool showingDetails = false; + + private: + WelcomeWindow& owner; + }; + + void updateStep() + { + if (steps.empty()) + return; + + const auto& step = steps[size_t(content->currentStep)]; + + content->titleLabel.setText(step.title, dontSendNotification); + content->descriptionEditor.setText(step.description); + // Scroll to top when changing steps + content->descriptionEditor.setCaretPosition(0); + + // Highlight logic + { + auto bounds = step.getHighlightBounds(mainComponent); + mainComponent->setTutorialHighlight(bounds); + + if (step.getExtraHighlights) + { + mainComponent->setTutorialExtraHighlights(step.getExtraHighlights(mainComponent)); + } + else + { + mainComponent->setTutorialExtraHighlights({}); + } + } + + // Buttons + content->prevButton.setVisible(content->currentStep > 0); + + bool isLast = content->currentStep == (int) steps.size() - 1; + bool isFirst = content->currentStep == 0; + // bool isSecond = content->currentStep == 1; // "Select a Model" + + content->nextButton.setButtonText(isLast ? "Finish" : "Next"); + content->skipButton.setVisible(true); + content->dontShowAgainToggle.setVisible(true); + + // Items specific to first page + content->learnMoreLink.setVisible(isFirst); + content->copyrightLabel.setVisible(isFirst); + + // Item specific to second page (Step 2) + // hostingEditor removed + // pyHarpLink removed + + content->pageIndicator.setText("Step " + String(content->currentStep + 1) + " of " + + String(steps.size()), + dontSendNotification); + + // Show details button only on Configure Parameters step + bool isConfigParams = step.title.contains("Configure Parameters"); + content->showDetailsButton.setVisible(isConfigParams); + + // Trigger layout update + content->resized(); + } + + void nextStep() + { + if (content->currentStep == 1 && mainComponent != nullptr) + { + auto model = mainComponent->getModelTab()->getModel(); + if (! model || ! model->isLoaded()) + { + pendingTutorialFallbackLoad = true; + mainComponent->ensureTutorialModelLoaded(); + return; + } + } + + if (content->currentStep < (int) steps.size() - 1) + { + content->currentStep++; + updateStep(); + } + else + { + finishTutorial(); + } + } + + void prevStep() + { + if (content->currentStep > 0) + { + content->currentStep--; + updateStep(); + } + } + + void skipTutorial() { finishTutorial(); } + + void finishTutorial() + { + String showWelcomePopupUponNextStartup = + ! content->dontShowAgainToggle.getToggleState() ? "1" : "0"; + Settings::setValue("view.showWelcomePopup", showWelcomePopupUponNextStartup, true); + + if (autoLoadedByTutorialFallback && mainComponent != nullptr) + mainComponent->resetTutorialAutoLoadedModel(); + + closeButtonPressed(); + } + + bool pendingTutorialFallbackLoad = false; + bool autoLoadedByTutorialFallback = false; + + std::vector steps; + + MainComponent* mainComponent; + WelcomeContent* content = nullptr; + + ComponentBoundsConstrainer constrainer; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(WelcomeWindow) };