diff --git a/Source/UI/LookAndFeel/CustomLookAndFeel.cpp b/Source/UI/LookAndFeel/CustomLookAndFeel.cpp index 42d9a02c9..3823dd5e5 100644 --- a/Source/UI/LookAndFeel/CustomLookAndFeel.cpp +++ b/Source/UI/LookAndFeel/CustomLookAndFeel.cpp @@ -255,6 +255,8 @@ void CustomLookAndFeel::setTheme (ColourTheme theme) setColour (BubbleComponent::backgroundColourId, currentThemeColours[ThemeColours::widgetBackground]); setColour (BubbleComponent::outlineColourId, currentThemeColours[ThemeColours::outline]); + + setColour (HyperlinkButton::textColourId, theme == ColourTheme::DARK ? Colours::dodgerblue : Colour (0xff1a0dab)); } //============================================================================== diff --git a/Source/UI/PluginInstaller.cpp b/Source/UI/PluginInstaller.cpp index f5cbbb94f..a0ab7d36d 100644 --- a/Source/UI/PluginInstaller.cpp +++ b/Source/UI/PluginInstaller.cpp @@ -35,8 +35,203 @@ #include #endif +#include + namespace fs = std::filesystem; +static inline File getPluginsDirectory(); +static inline File getSharedDirectory(); + +namespace +{ +constexpr auto pluginGatewayUrl = "https://open-ephys-plugin-gateway.herokuapp.com/"; + +struct InstalledPluginState +{ + HashMap versions; + HashMap dllNames; +}; + +struct PluginCatalog +{ + String downloadUrl; + var pluginData; + HashMap dependencyVersions; +}; + +StringArray getCompatibleVersions (const var& versions) +{ + StringArray compatibleVersions; + + if (auto* allVersions = versions.getArray()) + { + for (const auto& value : *allVersions) + { + const auto version = value.toString(); + const auto apiVer = version.fromLastOccurrenceOf ("API", false, false); + + if (apiVer.equalsIgnoreCase (String (PLUGIN_API_VER))) + compatibleVersions.add (version); + } + } + + return compatibleVersions; +} + +String getLatestCompatibleVersion (const var& versions) +{ + auto compatibleVersions = getCompatibleVersions (versions); + + if (compatibleVersions.isEmpty()) + return {}; + + compatibleVersions.sort (true); + return compatibleVersions[compatibleVersions.size() - 1]; +} + +String getLatestCompatibleVersionOrDefault (const var& versions) +{ + if (const auto latest = getLatestCompatibleVersion (versions); latest.isNotEmpty()) + return latest; + + return "0.0.0-API" + String (PLUGIN_API_VER); +} + +String getDisplayVersion (const String& version) +{ + return version.upToFirstOccurrenceOf ("-API", false, false); +} + +bool parseGatewayCatalog (PluginCatalog& catalog, String& errorMessage) +{ + const auto response = URL (pluginGatewayUrl).readEntireTextStream(); + + if (response.isEmpty()) + { + errorMessage = "Unable to fetch plugins! Please check your internet connection and try again."; + return false; + } + + var gatewayData; + if (const auto result = JSON::parse (response, gatewayData); result.failed()) + { + errorMessage = result.getErrorMessage(); + return false; + } + + catalog.downloadUrl = gatewayData.getProperty ("download_url", {}).toString(); + catalog.pluginData = gatewayData.getProperty ("plugins", {}); + catalog.dependencyVersions.clear(); + + if (const auto* plugins = catalog.pluginData.getArray()) + { + for (const auto& entry : *plugins) + { + const auto pluginName = entry.getProperty ("name", {}).toString(); + const auto pluginType = entry.getProperty ("type", {}).toString(); + + if (! pluginType.equalsIgnoreCase ("CommonLib")) + continue; + + if (const auto latestVersion = getLatestCompatibleVersion (entry.getProperty ("versions", {})); latestVersion.isNotEmpty()) + catalog.dependencyVersions.set (pluginName, latestVersion); + } + } + + return true; +} + +bool readInstalledPluginState (InstalledPluginState& installedState) +{ + const auto xmlFile = getPluginsDirectory().getChildFile ("installedPlugins.xml"); + XmlDocument doc (xmlFile); + std::unique_ptr xml (doc.getDocumentElement()); + + if (xml == nullptr || ! xml->hasTagName ("PluginInstaller")) + return false; + + installedState.versions.clear(); + installedState.dllNames.clear(); + + if (auto* child = xml->getFirstChildElement()) + { + for (auto* pluginElement : child->getChildIterator()) + { + const auto pluginName = pluginElement->getTagName(); + installedState.versions.set (pluginName, pluginElement->getStringAttribute ("version")); + installedState.dllNames.set (pluginName, pluginElement->getStringAttribute ("dllName")); + } + } + + return true; +} + +int findPluginIndexByProperty (const var& pluginData, const Identifier& property, const String& value) +{ + if (const auto* plugins = pluginData.getArray()) + { + for (int index = 0; index < plugins->size(); ++index) + { + if ((*plugins)[index].getProperty (property, {}).toString().equalsIgnoreCase (value)) + return index; + } + } + + return -1; +} + +SelectedPluginInfo createSelectedPluginInfo (const var& entry, + const InstalledPluginState& installedState, + const HashMap& dependencyVersions) +{ + SelectedPluginInfo pluginInfo; + pluginInfo.pluginName = entry.getProperty ("name", {}).toString(); + pluginInfo.displayName = entry.getProperty ("display_name", pluginInfo.pluginName).toString(); + pluginInfo.type = entry.getProperty ("type", {}).toString(); + pluginInfo.developers = entry.getProperty ("developers", {}).toString(); + + const auto updated = entry.getProperty ("updated", {}).toString(); + pluginInfo.lastUpdated = updated.upToFirstOccurrenceOf ("T", false, false); + pluginInfo.description = entry.getProperty ("desc", {}).toString(); + pluginInfo.docURL = entry.getProperty ("docs", {}).toString(); + pluginInfo.versions = getCompatibleVersions (entry.getProperty ("versions", {})); + pluginInfo.versions.sort (true); + pluginInfo.selectedVersion = {}; + pluginInfo.latestVersion = {}; + + if (! pluginInfo.versions.isEmpty()) + pluginInfo.latestVersion = pluginInfo.versions[pluginInfo.versions.size() - 1]; + pluginInfo.installedVersion = installedState.versions[pluginInfo.pluginName]; + pluginInfo.dependencies.clear(); + pluginInfo.dependencyVersions.clear(); + + if (auto* dependencies = entry.getProperty ("dependencies", {}).getArray()) + { + for (const auto& dependencyValue : *dependencies) + { + const auto dependency = dependencyValue.toString(); + + if (dependency.equalsIgnoreCase ("None")) + continue; + + pluginInfo.dependencies.add (dependency); + pluginInfo.dependencyVersions.add (dependencyVersions[dependency]); + } + } + + if (pluginInfo.selectedVersion.isEmpty()) + pluginInfo.selectedVersion = pluginInfo.latestVersion; + + if (pluginInfo.selectedVersion.isEmpty() && ! pluginInfo.versions.isEmpty()) + pluginInfo.selectedVersion = pluginInfo.versions[pluginInfo.versions.size() - 1]; + + if (pluginInfo.selectedVersion.isEmpty()) + pluginInfo.selectedVersion = pluginInfo.installedVersion; + + return pluginInfo; +} +} // namespace + //----------------------------------------------------------------------- static inline File getPluginsDirectory() { @@ -85,7 +280,7 @@ PluginInstaller::PluginInstaller (bool loadComponents) if (loadComponents) { - setSize (910, 480); + setSize (1180, 640); if (auto window = getActiveTopLevelWindow()) setCentrePosition (window->getScreenBounds().getCentre()); @@ -98,7 +293,7 @@ PluginInstaller::PluginInstaller (bool loadComponents) setContentOwned (new PluginInstallerComponent(), false); setVisible (true); setResizable (true, false); // useBottomCornerRisizer -- doesn't work very well - setResizeLimits (910, 480, 8192, 5120); + setResizeLimits (1180, 640, 8192, 5120); #ifdef __APPLE__ File iconDir = File::getSpecialLocation (File::currentApplicationFile).getChildFile ("Contents/Resources"); @@ -175,74 +370,41 @@ int PluginInstaller::checkForPluginUpdates() { LOGD ("Checking for plugin updates..."); - File xmlFile = getPluginsDirectory().getChildFile ("installedPlugins.xml"); - - XmlDocument doc (xmlFile); - std::unique_ptr xml (doc.getDocumentElement()); - - if (xml == 0 || ! xml->hasTagName ("PluginInstaller")) + InstalledPluginState installedState; + if (! readInstalledPluginState (installedState)) { LOGD ("[PluginInstaller] installedPlugins.xml not found."); return 0; } - auto child = xml->getFirstChildElement(); - - String baseUrl = "https://open-ephys-plugin-gateway.herokuapp.com/"; - String response = URL (baseUrl).readEntireTextStream(); - - if (response.isEmpty()) + PluginCatalog catalog; + String errorMessage; + if (! parseGatewayCatalog (catalog, errorMessage)) { LOGE ("Unable to fetch plugin updates! Please check your internet connection."); return 0; } - var gatewayData; - Result result = JSON::parse (response, gatewayData); - gatewayData = gatewayData.getProperty ("plugins", var()); - updatablePlugins.clear(); - for (auto* e : child->getChildIterator()) + if (const auto* plugins = catalog.pluginData.getArray()) { - String pName = e->getTagName(); - String latestVer; - - // Get latest compatible version for this plugin - for (int i = 0; i < gatewayData.size(); i++) + for (const auto& plugin : *plugins) { - if (gatewayData[i].getProperty ("name", "NULL").toString().equalsIgnoreCase (pName)) - { - auto allVersions = gatewayData[i].getProperty ("versions", "NULL").getArray(); - StringArray compatibleVersions; + const auto pluginName = plugin.getProperty ("name", {}).toString(); + const auto installedVersion = installedState.versions[pluginName]; - for (String depVersion : *allVersions) - { - String apiVer = depVersion.substring (depVersion.indexOf ("I") + 1); - - if (apiVer.equalsIgnoreCase (String (PLUGIN_API_VER))) - compatibleVersions.add (depVersion); - } + if (installedVersion.isEmpty()) + continue; - if (! compatibleVersions.isEmpty()) - { - compatibleVersions.sort (false); - latestVer = compatibleVersions[compatibleVersions.size() - 1]; - } - else - { - latestVer = "0.0.0-API" + String (PLUGIN_API_VER); - } + const auto latestVersion = getLatestCompatibleVersionOrDefault (plugin.getProperty ("versions", {})); - break; + if (latestVersion.compareNatural (installedVersion) > 0) + { + updatablePlugins.add (pluginName); + LOGD ("Plugin update available: ", pluginName); } } - - if (latestVer.isNotEmpty() && latestVer.compareNatural (e->getAttributeValue (0)) > 0) - { - updatablePlugins.add (pName); - LOGD ("Plugin update available: ", pName); - } } LOGD ("Found ", updatablePlugins.size(), " plugin(s) with updates available."); @@ -251,45 +413,30 @@ int PluginInstaller::checkForPluginUpdates() void PluginInstaller::installPluginAndDependency (const String& plugin, String version) { - PluginInfoComponent tempInfoComponent; - - /** Get list of plugins uploaded to Artifactory */ - String baseUrl = "https://open-ephys-plugin-gateway.herokuapp.com/"; - String response = URL (baseUrl).readEntireTextStream(); - - if (response.isEmpty()) - LOGE ("Unable to fetch plugins! Please check your internet connection and try again.") - - var gatewayData; - Result result = JSON::parse (response, gatewayData); - - String url = gatewayData.getProperty ("download_url", var()).toString(); - tempInfoComponent.setDownloadURL (url); - - var pluginData = gatewayData.getProperty ("plugins", var()); + PluginInstallActionRunner actionRunner; - int pIndex; - bool pluginFound = false; - - for (int i = 0; i < pluginData.size(); i++) + PluginCatalog catalog; + String errorMessage; + if (! parseGatewayCatalog (catalog, errorMessage)) { - if (pluginData[i].getProperty ("display_name", "NULL").toString().equalsIgnoreCase (plugin)) - { - pIndex = i; - pluginFound = true; - break; - } + LOGE (errorMessage) + return; } - if (! pluginFound) + actionRunner.setDownloadURL (catalog.downloadUrl); + + const auto pluginIndex = findPluginIndexByProperty (catalog.pluginData, "display_name", plugin); + + if (pluginIndex < 0) { LOGE ("Automated Plugin Installation Failed! Plugin not found!") return; } - auto platforms = pluginData[pIndex].getProperty ("platforms", "none").getArray(); + const auto selectedEntry = catalog.pluginData[pluginIndex]; + auto* platforms = selectedEntry.getProperty ("platforms", {}).getArray(); - if (! platforms->contains (osType)) + if (platforms == nullptr || ! platforms->contains (osType)) { LOGD ("No platform specific package found for ", plugin); return; @@ -299,68 +446,43 @@ void PluginInstaller::installPluginAndDependency (const String& plugin, String v SelectedPluginInfo requiredPluginInfo; - requiredPluginInfo.pluginName = pluginData[pIndex].getProperty ("name", "NULL").toString(); + requiredPluginInfo.pluginName = selectedEntry.getProperty ("name", {}).toString(); requiredPluginInfo.displayName = plugin; - requiredPluginInfo.type = pluginData[pIndex].getProperty ("type", "NULL").toString(); - - auto allVersions = pluginData[pIndex].getProperty ("versions", "NULL").getArray(); - - requiredPluginInfo.versions.clear(); - - for (String v : *allVersions) - { - String apiVer = v.substring (v.indexOf ("I") + 1); - - if (apiVer.equalsIgnoreCase (String (PLUGIN_API_VER))) - requiredPluginInfo.versions.add (v); - } + requiredPluginInfo.type = selectedEntry.getProperty ("type", {}).toString(); + requiredPluginInfo.versions = getCompatibleVersions (selectedEntry.getProperty ("versions", {})); requiredPluginInfo.dependencies.clear(); - auto dependencies = pluginData[pIndex].getProperty ("dependencies", "NULL").getArray(); - for (String dependency : *dependencies) + requiredPluginInfo.dependencyVersions.clear(); + + if (auto* dependencies = selectedEntry.getProperty ("dependencies", {}).getArray()) { - if (! dependency.equalsIgnoreCase ("None")) + for (const auto& dependencyValue : *dependencies) { - requiredPluginInfo.dependencies.add (dependency); - for (int i = 0; i < pluginData.size(); i++) - { - if (pluginData[i].getProperty ("name", "NULL").toString().equalsIgnoreCase (dependency)) - { - // Get the latest compatible version of the dependency - auto allDepVersions = pluginData[i].getProperty ("versions", "NULL").getArray(); - StringArray compatibleVersions; - for (String depVersion : *allDepVersions) - { - String apiVer = depVersion.substring (depVersion.indexOf ("I") + 1); + const auto dependency = dependencyValue.toString(); - if (apiVer.equalsIgnoreCase (String (PLUGIN_API_VER))) - compatibleVersions.add (depVersion); - } + if (dependency.equalsIgnoreCase ("None")) + continue; - if (! compatibleVersions.isEmpty()) - { - compatibleVersions.sort (false); - requiredPluginInfo.dependencyVersions.add (compatibleVersions[compatibleVersions.size() - 1]); - } - else - { - LOGE ("Automated Plugin Installation Failed! Compatible plugin version not found!") - return; - } + requiredPluginInfo.dependencies.add (dependency); - break; - } + if (const auto dependencyVersion = catalog.dependencyVersions[dependency]; dependencyVersion.isNotEmpty()) + { + requiredPluginInfo.dependencyVersions.add (dependencyVersion); + continue; } + + LOGE ("Automated Plugin Installation Failed! Compatible plugin version not found!") + return; } } - tempInfoComponent.setPluginInfo (requiredPluginInfo, false); + actionRunner.setPluginInfo (requiredPluginInfo); for (int i = 0; i < requiredPluginInfo.dependencies.size(); i++) { - tempInfoComponent.downloadPlugin (requiredPluginInfo.dependencies[i], - requiredPluginInfo.dependencyVersions[i], - true); + actionRunner.downloadPlugin (requiredPluginInfo.dependencies[i], + requiredPluginInfo.dependencyVersions[i], + true); } // download the plugin @@ -379,7 +501,7 @@ void PluginInstaller::installPluginAndDependency (const String& plugin, String v } } - int code = tempInfoComponent.downloadPlugin (requiredPluginInfo.pluginName, version, false); + int code = actionRunner.downloadPlugin (requiredPluginInfo.pluginName, version, false); if (code == 1) LOGC ("Install successful!!") @@ -387,761 +509,1107 @@ void PluginInstaller::installPluginAndDependency (const String& plugin, String v LOGC ("Install failed!!"); } -/* ================================== Plugin Installer Component ================================== */ +namespace +{ +String getDependenciesText (const SelectedPluginInfo& pluginInfo) +{ + return pluginInfo.dependencies.isEmpty() ? "None" : pluginInfo.dependencies.joinIntoString (", "); +} -PluginInstallerComponent::PluginInstallerComponent() : ThreadWithProgressWindow ("Plugin Installer", false, false), - checkForUpdates (false) +String getSelectedVersionOrFallback (const SelectedPluginInfo& pluginInfo) { - font = FontOptions ("Inter", "Regular", 18.0f); - setSize (getWidth() - 10, getHeight() - 10); + if (pluginInfo.selectedVersion.isNotEmpty()) + return pluginInfo.selectedVersion; + + if (pluginInfo.latestVersion.isNotEmpty()) + return pluginInfo.latestVersion; - addAndMakeVisible (pluginListAndInfo); - - //Auto check for updates on startup - checkForUpdates = true; - this->run(); - - addAndMakeVisible (sortingLabel); - sortingLabel.setFont (font); - sortingLabel.setText ("Sort By:", dontSendNotification); - - addAndMakeVisible (sortByMenu); - sortByMenu.setJustificationType (Justification::centred); - sortByMenu.addItem ("A - Z", 1); - sortByMenu.addItem ("Z - A", 2); - sortByMenu.setTextWhenNothingSelected ("-----"); - sortByMenu.addListener (this); - - addAndMakeVisible (viewLabel); - viewLabel.setFont (font); - viewLabel.setText ("View:", dontSendNotification); - - addAndMakeVisible (allButton); - allButton.setButtonText ("All"); - allButton.setRadioGroupId (101, dontSendNotification); - allButton.addListener (this); - allButton.setToggleState (true, dontSendNotification); - - addAndMakeVisible (installedButton); - installedButton.setButtonText ("Installed"); - installedButton.setClickingTogglesState (true); - installedButton.setRadioGroupId (101, dontSendNotification); - installedButton.addListener (this); - - addAndMakeVisible (updatesButton); - updatesButton.setButtonText ("Fetch Updates"); - updatesButton.changeWidthToFitText(); - updatesButton.addListener (this); - - addAndMakeVisible (typeLabel); - typeLabel.setFont (font); - typeLabel.setText ("Type:", dontSendNotification); - - addAndMakeVisible (filterType); - filterType.setButtonText ("Filter"); - filterType.addListener (this); - filterType.setToggleState (true, dontSendNotification); - - addAndMakeVisible (sourceType); - sourceType.setButtonText ("Source"); - sourceType.addListener (this); - sourceType.setToggleState (true, dontSendNotification); - - addAndMakeVisible (sinkType); - sinkType.setButtonText ("Sink"); - sinkType.addListener (this); - sinkType.setToggleState (true, dontSendNotification); - - addAndMakeVisible (otherType); - otherType.setButtonText ("Other"); - otherType.addListener (this); - otherType.setToggleState (true, dontSendNotification); + if (! pluginInfo.versions.isEmpty()) + return pluginInfo.versions[pluginInfo.versions.size() - 1]; + + return {}; } -void PluginInstallerComponent::paint (Graphics& g) +String getInstallActionLabel (const SelectedPluginInfo& pluginInfo) { - g.fillAll (findColour (ThemeColours::componentBackground).darker()); - g.setColour (findColour (ThemeColours::defaultText).withAlpha (0.5f)); - g.fillRect (195, 5, 2, 38); - g.fillRect (405, 5, 2, 38); + if (pluginInfo.versions.isEmpty()) + return "Unavailable"; + + const auto selectedVersion = getSelectedVersionOrFallback (pluginInfo); + + if (selectedVersion.isEmpty()) + return "Unavailable"; + + if (pluginInfo.installedVersion.isEmpty()) + return "Install"; + + const auto result = selectedVersion.compareNatural (pluginInfo.installedVersion); + + if (result == 0) + return "Installed"; + + return result > 0 ? "Upgrade" : "Downgrade"; } -void PluginInstallerComponent::resized() +bool canInstallPlugin (const SelectedPluginInfo& pluginInfo) +{ + const auto label = getInstallActionLabel (pluginInfo); + return label != "Installed" && label != "Unavailable"; +} + +Colour getInstallActionColour (const String& actionLabel) +{ + if (actionLabel == "Upgrade") + return Colours::green; + + if (actionLabel == "Downgrade") + return Colours::orangered.darker (0.1f); + + return Colours::dodgerblue.darker(); +} + +Path createSvgPath (std::initializer_list svgPathSegments) +{ + Path path; + + for (const auto* segment : svgPathSegments) + path.addPath (Drawable::parseSVGPath (segment)); + + return path; +} + +const Path& getInstallIconPath() +{ + static const auto path = createSvgPath ({ "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2", + "M7 11l5 5l5 -5", + "M12 4l0 12" }); + + return path; +} + +const Path& getUpgradeIconPath() { - sortingLabel.setBounds (20, 10, 70, 30); - sortByMenu.setBounds (90, 10, 90, 30); + static const auto path = createSvgPath ({ "M9 12h-3.586a1 1 0 0 1 -.707 -1.707l6.586 -6.586a1 1 0 0 1 1.414 0l6.586 6.586a1 1 0 0 1 -.707 1.707h-3.586v3h-6v-3", + "M9 21h6", + "M9 18h6" }); + + return path; +} - viewLabel.setBounds (200, 10, 50, 30); - allButton.setBounds (250, 11, 55, 28); - installedButton.setBounds (305, 11, 105, 28); +const Path& getDowngradeIconPath() +{ + static const auto path = createSvgPath ({ "M15 12h3.586a1 1 0 0 1 .707 1.707l-6.586 6.586a1 1 0 0 1 -1.414 0l-6.586 -6.586a1 1 0 0 1 .707 -1.707h3.586v-3h6v3", + "M15 3h-6", + "M15 6h-6" }); - typeLabel.setBounds (410, 11, 50, 28); - sourceType.setBounds (460, 11, 80, 28); - filterType.setBounds (540, 11, 70, 28); - sinkType.setBounds (610, 11, 65, 28); - otherType.setBounds (675, 11, 75, 28); + return path; +} - updatesButton.setBounds (getWidth() - 140, 11, 120, 28); +const Path& getInstallActionIconPath (const String& actionLabel) +{ + if (actionLabel == "Upgrade") + return getUpgradeIconPath(); + + if (actionLabel == "Downgrade") + return getDowngradeIconPath(); + + return getInstallIconPath(); +} + +const Path& getRemoveIconPath() +{ + static const auto path = createSvgPath ({ "M4 7h16", + "M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12", + "M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3", + "M10 12l4 4m0 -4l-4 4" }); - pluginListAndInfo.setBounds (10, 40, getWidth() - 10, getHeight() - 40); + return path; } -void PluginInstallerComponent::comboBoxChanged (ComboBox* comboBoxThatHasChanged) +class PluginIconButton : public Button { - if (comboBoxThatHasChanged->getSelectedId() == 1) +public: + enum class IconType + { + install, + remove + }; + + explicit PluginIconButton (IconType iconType) + : Button (iconType == IconType::install ? "Install" : "Remove"), + icon (iconType) { - pluginListAndInfo.pluginArray.sort (true); - pluginListAndInfo.repaint(); } - else if (comboBoxThatHasChanged->getSelectedId() == 2) + + void paintButton (Graphics& g, bool isMouseOverButton, bool isButtonDown) override { - pluginListAndInfo.pluginArray.sort (true); - int size = pluginListAndInfo.pluginArray.size(); - for (int i = 0; i < size / 2; i++) + auto bounds = getLocalBounds().toFloat().reduced (2.0f); + const auto enabled = isEnabled(); + const auto actionLabel = getButtonText(); + + auto background = findColour (ThemeColours::widgetBackground); + auto outline = hasKeyboardFocus (false) ? findColour (ThemeColours::highlightedFill) : findColour (ThemeColours::outline); + outline = outline.withAlpha (enabled ? 1.0f : 0.4f); + auto iconColour = Colours::dodgerblue; + + if (icon == IconType::install) + iconColour = getInstallActionColour (actionLabel); + else if (icon == IconType::remove) + iconColour = Colours::red; + + iconColour = iconColour.withAlpha (enabled ? 0.95f : 0.28f); + + float iconScaleDelta = 6.0f; + + if (enabled && isMouseOverButton) + { + background = background.brighter (isButtonDown ? 0.08f : 0.16f); + iconScaleDelta = 5.0f; + } + + if (enabled && isButtonDown) { - pluginListAndInfo.pluginArray.getReference (i).swapWith (pluginListAndInfo.pluginArray.getReference (size - i - 1)); + outline = outline.withAlpha (0.42f); + iconScaleDelta = 7.0f; } - pluginListAndInfo.repaint(); + g.setColour (background); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (outline); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); + + auto iconBounds = bounds.reduced (iconScaleDelta); + const auto& iconPath = icon == IconType::install ? getInstallActionIconPath (actionLabel) : getRemoveIconPath(); + auto transform = iconPath.getTransformToScaleToFit (iconBounds, true, Justification::centred); + + g.setColour (iconColour); + g.strokePath (iconPath, PathStrokeType (2.0f, PathStrokeType::curved, PathStrokeType::rounded), transform); } -} -void PluginInstallerComponent::run() -{ - File xmlFile = getPluginsDirectory().getChildFile ("installedPlugins.xml"); + MouseCursor getMouseCursor() override + { + if (isEnabled()) + return MouseCursor::PointingHandCursor; - XmlDocument doc (xmlFile); - std::unique_ptr xml (doc.getDocumentElement()); + return MouseCursor::NormalCursor; + } - if (xml == 0 || ! xml->hasTagName ("PluginInstaller")) +private: + IconType icon; +}; + +class PluginNameCell : public Component +{ +public: + PluginNameCell() + : plainFont (FontOptions (14.0f)), + linkFont (FontOptions (14.0f, Font::underlined)) { - LOGE ("[PluginInstaller] File not found."); - return; + addAndMakeVisible (linkButton); + linkButton.setJustificationType (Justification::centredLeft); } - else + + void update (const SelectedPluginInfo& pluginInfo, const FontOptions& fontOptions) { - installedPlugins.clear(); - auto child = xml->getFirstChildElement(); + hasDocs = pluginInfo.docURL.isNotEmpty(); + hasUpdate = pluginInfo.hasUpdate; + displayName = pluginInfo.displayName + (hasDocs ? " ↗" : ""); + plainFont = Font (fontOptions); + linkFont = Font (fontOptions.withUnderline (true)); - String baseUrl = "https://open-ephys-plugin-gateway.herokuapp.com/"; - var gatewayData; - if (checkForUpdates) + linkButton.setVisible (hasDocs); + linkButton.setEnabled (hasDocs); + linkButton.setButtonText (displayName); + linkButton.setURL (hasDocs ? URL (pluginInfo.docURL) : URL()); + linkButton.setFont (linkFont, false, Justification::centredLeft); + + resized(); + repaint(); + } + + void paint (Graphics& g) override + { + if (! hasDocs) { - setStatusMessage ("Fetching plugin updates..."); - updatablePlugins.clear(); + g.setColour (findColour (ThemeColours::defaultText)); + g.setFont (plainFont); + g.drawText (displayName.isNotEmpty() ? displayName : String ("-"), + getLocalBounds().reduced (5, 0), + Justification::centredLeft, + true); + } + } + + void resized() override + { + auto bounds = getLocalBounds().reduced (5, 0); + linkButton.setBounds (bounds); + linkButton.changeWidthToFitText(); + } - String response = URL (baseUrl).readEntireTextStream(); +private: + HyperlinkButton linkButton; + String displayName; + Font plainFont; + Font linkFont; + bool hasDocs = false; + bool hasUpdate = false; +}; - if (response.isEmpty()) - { - LOGE ("Unable to fetch updates! Please check you internet connection and try again.") +class PluginVersionCell : public Component +{ +public: + PluginVersionCell() + { + addAndMakeVisible (versionMenu); + versionMenu.setJustificationType (Justification::centred); + versionMenu.setTextWhenNoChoicesAvailable ("- N/A -"); + versionMenu.onChange = [this] + { + if (onVersionChanged == nullptr) return; - } - Result result = JSON::parse (response, gatewayData); - gatewayData = gatewayData.getProperty ("plugins", var()); - } + const auto selectedIndex = versionMenu.getSelectedId() - 1; - for (auto* e : child->getChildIterator()) + if (isPositiveAndBelow (selectedIndex, versions.size())) + onVersionChanged (versions[selectedIndex]); + }; + } + + void update (const SelectedPluginInfo& pluginInfo, + std::function callback) + { + onVersionChanged = std::move (callback); + versions = pluginInfo.versions; + + versionMenu.clear (dontSendNotification); + + for (int index = 0; index < pluginInfo.versions.size(); ++index) + versionMenu.addItem (getDisplayVersion (pluginInfo.versions[index]), index + 1); + + const auto selectedVersion = getSelectedVersionOrFallback (pluginInfo); + const auto selectedIndex = pluginInfo.versions.indexOf (selectedVersion); + + if (selectedIndex >= 0) + versionMenu.setSelectedId (selectedIndex + 1, dontSendNotification); + + versionMenu.setEnabled (! pluginInfo.versions.isEmpty()); + } + + void resized() override + { + versionMenu.setBounds (getLocalBounds().reduced (2, 20)); + } + +private: + ComboBox versionMenu; + StringArray versions; + std::function onVersionChanged; +}; + +class PluginDescriptionCalloutComponent : public Component +{ +public: + PluginDescriptionCalloutComponent (String descriptionToShow, Font descriptionFont) + : font (std::move (descriptionFont)) + { + descriptionText.append (std::move (descriptionToShow), + font, + findColour (ThemeColours::defaultText)); + descriptionText.setJustification (Justification::topLeft); + descriptionText.setWordWrap (AttributedString::WordWrap::byWord); + descriptionLayout.createLayout (descriptionText, static_cast (contentWidth - padding * 2)); + setSize (contentWidth, getPreferredHeight()); + } + + void paint (Graphics& g) override + { + g.setColour (findColour (ThemeColours::widgetBackground)); + g.fillRoundedRectangle (getLocalBounds().toFloat().reduced (1.0f), 5.0f); + descriptionLayout.draw (g, getLocalBounds().reduced (padding).toFloat()); + } + +private: + int getPreferredHeight() const + { + return jlimit (40, 500, roundToInt (descriptionLayout.getHeight()) + padding * 2); + } + + static constexpr int contentWidth = 360; + static constexpr int padding = 12; + + AttributedString descriptionText; + TextLayout descriptionLayout; + Font font; +}; + +class PluginIconButtonCell : public Component +{ +public: + explicit PluginIconButtonCell (PluginIconButton::IconType iconType) + : button (iconType) + { + addAndMakeVisible (button); + button.onClick = [this] { - String pName = e->getTagName(); - installedPlugins.add (pName); + if (onClick != nullptr) + onClick(); + }; + } + + void update (const String& label, + bool isEnabled, + std::function callback) + { + onClick = std::move (callback); + button.setButtonText (label); + button.setEnabled (isEnabled); + button.setTooltip (label); + } + + void resized() override + { + button.setBounds (getLocalBounds().reduced (2, 18)); + } + +private: + PluginIconButton button; + std::function onClick; +}; +} // namespace + +/* ================================== Plugin Installer Component ================================== */ + +PluginInstallerComponent::PluginInstallerComponent() +{ + font = FontOptions ("Inter", "Medium", 17.0f); + setSize (getWidth() - 10, getHeight() - 10); + setWantsKeyboardFocus (true); + + searchLabel = std::make_unique