diff --git a/CMakeLists.txt b/CMakeLists.txt index 1b05d238..bedbd289 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,10 +93,12 @@ target_sources(${PROJECT_NAME} src/gui/HoverHandler.h src/gui/ControlComponent.h src/gui/ToggleWithLabel.h + src/gui/FileChooserWithLabel.h src/media/MediaDisplayComponent.cpp src/media/AudioDisplayComponent.cpp src/media/MidiDisplayComponent.cpp + src/media/FileDisplayComponent.cpp src/media/OutputLabelComponent.cpp src/media/pianoroll/KeyboardComponent.cpp diff --git a/pyharp b/pyharp index be3e7540..80b265ea 160000 --- a/pyharp +++ b/pyharp @@ -1 +1 @@ -Subproject commit be3e7540442e3c85fc1c5d2daf4675f8ffa88916 +Subproject commit 80b265ea1adf3fded4e2f3963a28062215e21e0d diff --git a/src/Model.h b/src/Model.h index eb205086..ac0e01d7 100644 --- a/src/Model.h +++ b/src/Model.h @@ -259,6 +259,31 @@ class Model } } + std::map fileControlRemotePaths; + + for (const auto& controlComponent : controlComponents) + { + if (auto* fileComponentInfo = dynamic_cast(controlComponent.get())) + { + if (fileComponentInfo->path.empty()) + continue; + + String remoteFilePath; + + result = + client->uploadFile(loadedPath, File(fileComponentInfo->path), remoteFilePath); + + if (result.failed()) + { + setStatus(ModelStatus::FAILURE); + + return result; + } + + fileControlRemotePaths[fileComponentInfo->id] = remoteFilePath.toStdString(); + } + } + /* Extract control values for JSON payload in order */ Array controlValues; @@ -314,6 +339,25 @@ class Model { controlValue = var(comboBoxComponentInfo->value); } + else if (auto fileComponentInfo = dynamic_cast(componentInfo.get())) + { + auto it = fileControlRemotePaths.find(fileComponentInfo->id); + + if (it == fileControlRemotePaths.end() || it->second.empty()) + { + controlValue = var(); + } + else + { + DynamicObject::Ptr fileObj = new DynamicObject(); + + fileObj->setProperty("path", var(it->second)); + + controlValue = var(fileObj); + } + + wasFile = true; + } else { // Unsupported control was added @@ -440,6 +484,17 @@ class Model DBG_AND_LOG("Model::extractInputs: MIDI track input \"" + midiTrack->label + "\" extracted."); } + else if (type == "generic_file") + { + std::shared_ptr fileChooser = + std::make_shared(controlsDict); + + newControls.push_back(fileChooser); + tempComponentIDs.push_back(fileChooser->id); + + DBG_AND_LOG("Model::extractInputs: File chooser control \"" + fileChooser->label + + "\" extracted."); + } else if (type == "text_box") { std::shared_ptr textControl = @@ -563,6 +618,16 @@ class Model DBG_AND_LOG("Model::extractOutputs: MIDI track output \"" + midiTrack->label + "\" extracted."); } + else if (type == "generic_file") + { + std::shared_ptr fileOutput = + std::make_shared(controlsDict); + + newOutputs.push_back(fileOutput); + + DBG_AND_LOG("Model::extractOutputs: File output \"" + fileOutput->label + + "\" extracted."); + } else if (type == "json") { // Labels are handled separately diff --git a/src/ModelTab.h b/src/ModelTab.h index 3c172ede..f73a0bba 100644 --- a/src/ModelTab.h +++ b/src/ModelTab.h @@ -487,6 +487,23 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas } } + for (const auto& controlInfo : model->getControls()) + { + if (auto* fileInfo = dynamic_cast(controlInfo.get())) + { + if (fileInfo->required && fileInfo->path.empty()) + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "Error", + "Required file input \"" + String(fileInfo->label) + + "\" is empty. Please select a file before processing."); + + return; + } + } + } + modelSelectionWidget.setDisabled(); processCancelButton.setMode(cancelButtonInfo.displayLabel); @@ -536,7 +553,9 @@ class ModelTab : public Component, private ChangeListener, public ChangeBroadcas { auto& outputMediaDisplays = outputTrackAreaWidget.getMediaDisplays(); - for (size_t i = 0; i < outputMediaDisplays.size(); ++i) + for (size_t i = 0; + i < outputMediaDisplays.size() && i < outputFilesPtr->size(); + ++i) { outputMediaDisplays[i]->initializeDisplay( URL((*outputFilesPtr)[i])); diff --git a/src/gui/FileChooserWithLabel.h b/src/gui/FileChooserWithLabel.h new file mode 100644 index 00000000..2e3d5f12 --- /dev/null +++ b/src/gui/FileChooserWithLabel.h @@ -0,0 +1,204 @@ +/** + * @file FileChooserWithLabel.h + * @brief Custom file chooser component with label. + * @author derekllanes, cwitkowitz + */ + +#pragma once + +#include +#include + +#include "ControlComponent.h" +#include "MultiButton.h" + +using namespace juce; + +class FileChooserWithLabel : public ControlComponent, public FileDragAndDropTarget +{ +public: + ~FileChooserWithLabel() { actionButton.setLookAndFeel(nullptr); } + + FileChooserWithLabel(const String& labelText = {}) + { + label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); + addAndMakeVisible(label); + + initializeButton(); + addAndMakeVisible(actionButton); + } + + void resized() override + { + auto area = getLocalBounds(); + label.setBounds(area.removeFromTop(labelHeight)); + int buttonSize = area.getHeight(); + actionButton.setBounds(area.removeFromRight(buttonSize)); + } + + void paint(Graphics& g) override + { + auto body = getLocalBounds().withTrimmedTop(labelHeight).toFloat(); + auto bodyInt = body.toNearestInt(); + float r = 3.0f; + + auto bg = findColour(ComboBox::backgroundColourId); + auto outline = findColour(ComboBox::outlineColourId); + auto textCol = findColour(ComboBox::textColourId); + + g.setColour(bg); + g.fillRoundedRectangle(body.reduced(0.5f), r); + g.setColour(outline); + g.drawRoundedRectangle(body.reduced(0.5f), r, 1.0f); + + auto textArea = bodyInt.withTrimmedRight(bodyInt.getHeight()).withTrimmedLeft(4); + + g.setFont(Font(13.0f)); + g.setColour(currentPath.isEmpty() ? textCol.withAlpha(0.4f) : textCol.withAlpha(0.85f)); + g.drawText(currentPath.isEmpty() ? getPlaceholderText() : File(currentPath).getFileName(), + textArea, + Justification::centredLeft, + true); + } + + bool isInterestedInFileDrag(const StringArray&) override { return currentPath.isEmpty(); } + + void filesDropped(const StringArray& files, int, int) override + { + if (! files.isEmpty()) + { + File f(files[0]); + + if (f.existsAsFile()) + setAndNotify(f.getFullPathName()); + } + } + + void setPath(const String& path) + { + currentPath = path; + actionButton.setMode(currentPath.isEmpty() ? chooseFileModeInfo.displayLabel + : removeFileModeInfo.displayLabel); + repaint(); + } + + void setFileTypes(const std::vector& types) { fileTypes = types; } + + int getMinimumRequiredWidth() const override + { + const int labelWidth = getLabelWidth(label); + return jmax(minFilePickerWidth, labelWidth + defaultPadding); + } + + std::function onFileSelected; + +private: + void initializeButton() + { + chooseFileModeInfo = MultiButton::Mode { "ChooseFile", + "Click to choose a file.", + [this] { launchFileChooser(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Folder }; + + removeFileModeInfo = MultiButton::Mode { "RemoveFile", + "Click to remove the selected file.", + [this] { clearFile(); }, + MultiButton::DrawingMode::IconOnly, + Colours::orangered, + fontawesome::Remove }; + + actionButton.addMode(chooseFileModeInfo); + actionButton.addMode(removeFileModeInfo); + actionButton.setMode(chooseFileModeInfo.displayLabel); + + actionButton.setLookAndFeel(&noBorderLAF); + } + + String getPlaceholderText() const + { + if (fileTypes.empty()) + return "No file selected"; + + StringArray exts; + + for (const auto& ext : fileTypes) + { + String e(ext); + exts.add(e.startsWithChar('.') ? e : "." + e); + } + + return "No file selected (" + exts.joinIntoString(", ") + ")"; + } + + void clearFile() + { + setPath({}); + + if (onFileSelected) + onFileSelected({}); + } + + void setAndNotify(const String& path) + { + setPath(path); + + if (onFileSelected) + onFileSelected(path); + } + + void launchFileChooser() + { + fileChooser = std::make_unique("Select file", File(), buildWildcardPattern()); + + fileChooser->launchAsync(FileBrowserComponent::openMode + | FileBrowserComponent::canSelectFiles, + [this](const FileChooser& chooser) + { + const File f = chooser.getResult(); + + if (f.existsAsFile()) + setAndNotify(f.getFullPathName()); + }); + } + + String buildWildcardPattern() const + { + if (fileTypes.empty()) + return "*"; + + StringArray patterns; + + for (const auto& ext : fileTypes) + { + String e(ext); + + if (! e.startsWithChar('.')) + e = "." + e; + + patterns.add("*" + e); + } + + return patterns.joinIntoString(";"); + } + + struct NoBorderLookAndFeel : public LookAndFeel_V4 + { + void drawButtonBackground(Graphics&, Button&, const Colour&, bool, bool) override {} + }; + + static constexpr int minFilePickerWidth = 260; + static constexpr int labelHeight = 20; + + NoBorderLookAndFeel noBorderLAF; + Label label; + MultiButton actionButton; + MultiButton::Mode chooseFileModeInfo; + MultiButton::Mode removeFileModeInfo; + + String currentPath; + std::vector fileTypes; + std::unique_ptr fileChooser; +}; diff --git a/src/gui/TextBoxWithLabel.h b/src/gui/TextBoxWithLabel.h index 507781a1..ecb22163 100644 --- a/src/gui/TextBoxWithLabel.h +++ b/src/gui/TextBoxWithLabel.h @@ -16,6 +16,7 @@ class TextBoxWithLabel : public ControlComponent TextBoxWithLabel(const String& labelText) { label.setText(labelText, dontSendNotification); + label.setJustificationType(Justification::centred); textBox.setMultiLine(true, true); textBox.setReadOnly(false); diff --git a/src/media/FileDisplayComponent.cpp b/src/media/FileDisplayComponent.cpp new file mode 100644 index 00000000..c0d9f2a8 --- /dev/null +++ b/src/media/FileDisplayComponent.cpp @@ -0,0 +1,110 @@ +#include "FileDisplayComponent.h" + +FileDisplayComponent::FileDisplayComponent() : FileDisplayComponent("File Track") {} + +FileDisplayComponent::FileDisplayComponent(String name, bool req, bool fromDAW, DisplayMode mode) + : MediaDisplayComponent(name, req, fromDAW, mode) +{ + downloadActiveMode = MultiButton::Mode { "Download", + "Click to save the output file.", + [this] { saveFileCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Save }; + downloadInactiveMode = + MultiButton::Mode { "Download-Inactive", "No output file available.", + [this] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontawesome::Save }; + downloadButton.addMode(downloadActiveMode); + downloadButton.addMode(downloadInactiveMode); + downloadButton.setMode(downloadInactiveMode.displayLabel); + downloadButton.setLookAndFeel(&noBorderLAF); + + addAndMakeVisible(downloadButton); +} + +FileDisplayComponent::~FileDisplayComponent() { downloadButton.setLookAndFeel(nullptr); } + +StringArray FileDisplayComponent::getSupportedExtensions() +{ + return {}; // Empty = accept all extensions +} + +StringArray FileDisplayComponent::getInstanceExtensions() +{ + return instanceFileTypes; // Empty = accept all; populated from model metadata when provided +} + +void FileDisplayComponent::setInstanceFileTypes(const std::vector& types) +{ + instanceFileTypes.clear(); + + for (const auto& t : types) + { + String ext(t); + + if (! ext.startsWithChar('.')) + ext = "." + ext; + + instanceFileTypes.add(ext); + } +} + +double FileDisplayComponent::getTotalLengthInSecs() { return 0.0; } + +void FileDisplayComponent::paint(Graphics& g) +{ + auto body = getLocalBounds().withTrimmedTop(labelHeight).toFloat(); + auto bodyInt = body.toNearestInt(); + float r = 3.0f; + + auto bg = findColour(ComboBox::backgroundColourId); + auto outline = findColour(ComboBox::outlineColourId); + auto textCol = findColour(ComboBox::textColourId); + + // Draw label area + g.setFont(Font(14.0f)); + g.setColour(textCol); + g.drawText( + getTrackName(), getLocalBounds().removeFromTop(labelHeight), Justification::centred, true); + + // Draw body background + g.setColour(bg); + g.fillRoundedRectangle(body.reduced(0.5f), r); + g.setColour(outline); + g.drawRoundedRectangle(body.reduced(0.5f), r, 1.0f); + + // Draw filename text (leaves space for the square button on the right) + auto textArea = bodyInt.withTrimmedRight(bodyInt.getHeight()).withTrimmedLeft(4); + + g.setFont(Font(13.0f)); + g.setColour(isFileLoaded() ? textCol.withAlpha(0.85f) : textCol.withAlpha(0.4f)); + g.drawText(isFileLoaded() ? getOriginalFilePath().getFileName() : "No output file", + textArea, + Justification::centredLeft, + true); +} + +void FileDisplayComponent::resized() +{ + auto area = getLocalBounds().withTrimmedTop(labelHeight); + int buttonSize = area.getHeight(); + downloadButton.setBounds(area.removeFromRight(buttonSize)); +} + +void FileDisplayComponent::loadMediaFile(const URL& /*filePath*/) +{ + downloadButton.setMode(downloadActiveMode.displayLabel); + repaint(); +} + +void FileDisplayComponent::resetMedia() +{ + downloadButton.setMode(downloadInactiveMode.displayLabel); + repaint(); +} + +void FileDisplayComponent::postLoadActions(const URL& /*filePath*/) +{ + // No extra action needed for generic files. +} diff --git a/src/media/FileDisplayComponent.h b/src/media/FileDisplayComponent.h new file mode 100644 index 00000000..579788a7 --- /dev/null +++ b/src/media/FileDisplayComponent.h @@ -0,0 +1,49 @@ +#pragma once + +#include "MediaDisplayComponent.h" + +class FileDisplayComponent : public MediaDisplayComponent +{ +public: + FileDisplayComponent(); + FileDisplayComponent(String name, + bool req = true, + bool fromDAW = false, + DisplayMode mode = DisplayMode::Input); + ~FileDisplayComponent() override; + + static StringArray getSupportedExtensions(); + + StringArray getInstanceExtensions() override; + + int getFixedHeight() const override { return fixedHeight; } + + double getTotalLengthInSecs() override; + + void paint(Graphics& g) override; + void resized() override; + + void loadMediaFile(const URL& filePath) override; + void resetMedia() override; + + void postLoadActions(const URL& filePath) override; + + void setInstanceFileTypes(const std::vector& types); + +private: + struct NoBorderLookAndFeel : public LookAndFeel_V4 + { + void drawButtonBackground(Graphics&, Button&, const Colour&, bool, bool) override {} + }; + + NoBorderLookAndFeel noBorderLAF; + + MultiButton downloadButton; + MultiButton::Mode downloadActiveMode; + MultiButton::Mode downloadInactiveMode; + + StringArray instanceFileTypes; + + static constexpr int fixedHeight = 50; + static constexpr int labelHeight = 20; +}; diff --git a/src/media/MediaDisplayComponent.h b/src/media/MediaDisplayComponent.h index 39b91e58..d58687a9 100644 --- a/src/media/MediaDisplayComponent.h +++ b/src/media/MediaDisplayComponent.h @@ -89,6 +89,9 @@ class MediaDisplayComponent : public Component, String getTrackName() { return trackName; } bool isRequired() const { return required; } + + // Returns a fixed height in pixels this display should occupy, or 0 to use flex sizing. + virtual int getFixedHeight() const { return 0; } bool isLinkedToDAW() const { return linkedToDAW; } bool isInputTrack() { return (displayMode == DisplayMode::Input) || isHybridTrack(); } diff --git a/src/utils/Controls.h b/src/utils/Controls.h index 051cb9c0..422b85fc 100644 --- a/src/utils/Controls.h +++ b/src/utils/Controls.h @@ -83,6 +83,51 @@ struct MidiTrackComponentInfo : public TrackComponentInfo using TrackComponentInfo::TrackComponentInfo; }; +struct FileComponentInfo : public ModelComponentInfo // TODO - Listener? +{ + bool required = true; + + std::string path { "" }; + std::vector fileTypes; + + FileComponentInfo(DynamicObject* input) : ModelComponentInfo(input) + { + if (input->hasProperty("required")) + { + required = stringToBool(input->getProperty("required").toString()); + } + + if (input->hasProperty("path")) + { + path = input->getProperty("path").toString().toStdString(); + } + + if (input->hasProperty("file_types")) + { + Array* types = input->getProperty("file_types").getArray(); + + if (types == nullptr) + { + // TODO - handle error case: couldn't load types + } + + int numTypes = types->size(); + + if (numTypes > 0) + { + for (int j = 0; j < numTypes; j++) + { + fileTypes.push_back(types->getReference(j).toString().toStdString()); + } + } + else + { + // TODO - handle error case: no types + } + } + } +}; + struct TextBoxComponentInfo : public ModelComponentInfo, public TextEditor::Listener { std::string value { "" }; diff --git a/src/widgets/ControlAreaWidget.h b/src/widgets/ControlAreaWidget.h index 58c8ec98..770be1c4 100644 --- a/src/widgets/ControlAreaWidget.h +++ b/src/widgets/ControlAreaWidget.h @@ -12,6 +12,7 @@ #include "../widgets/StatusAreaWidget.h" #include "../gui/ComboBoxWithLabel.h" +#include "../gui/FileChooserWithLabel.h" #include "../gui/HoverHandler.h" #include "../gui/SliderWithLabel.h" #include "../gui/TextBoxWithLabel.h" @@ -81,7 +82,7 @@ class ControlAreaWidget : public Component int getNumControls() const { return sliderComponents.size() + toggleComponents.size() + dropdownComponents.size() - + textComponents.size(); + + textComponents.size() + fileChooserComponents.size(); } int getMinimumRequiredWidth() const @@ -100,6 +101,7 @@ class ControlAreaWidget : public Component checkGroup(toggleComponents); checkGroup(dropdownComponents); checkGroup(textComponents); + checkGroup(fileChooserComponents); return requiredWidth + 2 * (marginSize + minEdgeGap); } @@ -156,6 +158,12 @@ class ControlAreaWidget : public Component } dropdownComponents.clear(); + for (auto& c : fileChooserComponents) + { + removeChildComponent(c.get()); + } + fileChooserComponents.clear(); + handlers.clear(); } @@ -182,6 +190,10 @@ class ControlAreaWidget : public Component { addDropdown(dropdownInfo); } + else if (auto* fileChooserInfo = dynamic_cast(info.get())) + { + addFileChooser(fileChooserInfo); + } else { // Unsupported control detected @@ -287,6 +299,26 @@ class ControlAreaWidget : public Component dropdownComponents.push_back(std::move(dropdownComponent)); } + void addFileChooser(FileComponentInfo* info) + { + std::unique_ptr fileChooserComponent = + std::make_unique(info->label); + + if (! info->path.empty()) + fileChooserComponent->setPath(info->path); + + fileChooserComponent->setFileTypes(info->fileTypes); + + fileChooserComponent->onFileSelected = [info](const String& path) + { info->path = path.toStdString(); }; + + addHandler(fileChooserComponent.get(), info); + + addAndMakeVisible(*fileChooserComponent); + + fileChooserComponents.push_back(std::move(fileChooserComponent)); + } + void addHandler(Component* comp, ModelComponentInfo* info) { std::unique_ptr handler = std::make_unique(*comp); @@ -341,12 +373,14 @@ class ControlAreaWidget : public Component addGroupToRows(rows, toggleComponents, 1, width); addGroupToRows(rows, dropdownComponents, 2, width); addGroupToRows(rows, textComponents, 3, width); + addGroupToRows(rows, fileChooserComponents, 4, width); return rows; } + template void addGroupToRows(std::vector>& rows, - const auto& components, + const ComponentList& components, int type, int availableWidth) const { @@ -403,6 +437,8 @@ class ControlAreaWidget : public Component return { preferredDropdownWidth, minDropdownHeight }; case 3: return { preferredTextBoxWidth, minTextBoxHeight }; + case 4: + return { preferredFilePickerWidth, minFilePickerHeight }; } return { preferredDropdownWidth, minDropdownHeight }; @@ -426,11 +462,13 @@ class ControlAreaWidget : public Component static constexpr int minToggleHeight = 34; static constexpr int minDropdownHeight = 44; static constexpr int minTextBoxHeight = 84; + static constexpr int minFilePickerHeight = 50; static constexpr int preferredSliderWidth = 108; static constexpr int preferredToggleWidth = 112; static constexpr int preferredDropdownWidth = 140; static constexpr int preferredTextBoxWidth = 200; + static constexpr int preferredFilePickerWidth = 260; static constexpr int minInterItemGap = 6; static constexpr int minEdgeGap = 4; @@ -441,6 +479,7 @@ class ControlAreaWidget : public Component std::vector> toggleComponents; std::vector> sliderComponents; std::vector> dropdownComponents; + std::vector> fileChooserComponents; std::vector> handlers; diff --git a/src/widgets/TrackAreaWidget.h b/src/widgets/TrackAreaWidget.h index f81f7765..f78ac9f7 100644 --- a/src/widgets/TrackAreaWidget.h +++ b/src/widgets/TrackAreaWidget.h @@ -9,6 +9,7 @@ #include #include "../media/AudioDisplayComponent.h" +#include "../media/FileDisplayComponent.h" #include "../media/MediaDisplayComponent.h" #include "../media/MidiDisplayComponent.h" @@ -51,10 +52,16 @@ class TrackAreaWidget : public Component, { FlexItem i = FlexItem(*m); + int fixedH = m->getFixedHeight(); + if (fixedTrackHeight) { i = i.withHeight(fixedTrackHeight); } + else if (fixedH > 0) + { + i = i.withHeight(fixedH).withFlex(0); + } else { i = i.withFlex(1).withMinHeight(50); @@ -243,6 +250,25 @@ class TrackAreaWidget : public Component, } } + void addFileOutputFromComponentInfo(FileComponentInfo* fileInfo) + { + std::string label = fileInfo->label.empty() ? "File Output" : fileInfo->label; + + auto m = std::make_unique(String(label), false, false, displayMode); + + m->setTrackID(fileInfo->id); + m->setInstanceFileTypes(fileInfo->fileTypes); + + if (! fileInfo->info.empty()) + m->setMediaInstructions(fileInfo->info); + + m->addChangeListener(this); + addAndMakeVisible(m.get()); + mediaDisplays.push_back(std::move(m)); + + resized(); + } + void updateTracks(const ModelComponentInfoList& trackComponents) { resetState(); @@ -253,9 +279,13 @@ class TrackAreaWidget : public Component, { addTrackFromComponentInfo(trackInfo); } + else if (auto* fileInfo = dynamic_cast(info.get())) + { + addFileOutputFromComponentInfo(fileInfo); + } else { - // Invalid input track + // Invalid track component jassertfalse; } }