From ccfb8391be066bf5f360d880bde3bc90d6d9d4df Mon Sep 17 00:00:00 2001 From: James H Ball Date: Sun, 17 Aug 2025 20:53:49 +0100 Subject: [PATCH] Make grid component more generic --- Source/audio/PerspectiveEffect.h | 2 +- Source/components/EffectTypeGridComponent.cpp | 129 +++-------------- Source/components/EffectTypeGridComponent.h | 19 +-- Source/components/GridComponent.cpp | 131 ++++++++++++++++++ Source/components/GridComponent.h | 33 +++++ Source/components/GridItemComponent.cpp | 123 ++++++++++++++++ Source/components/GridItemComponent.h | 39 ++++++ osci-render.jucer | 7 + 8 files changed, 357 insertions(+), 126 deletions(-) create mode 100644 Source/components/GridComponent.cpp create mode 100644 Source/components/GridComponent.h create mode 100644 Source/components/GridItemComponent.cpp create mode 100644 Source/components/GridItemComponent.h diff --git a/Source/audio/PerspectiveEffect.h b/Source/audio/PerspectiveEffect.h index 3e047d0..f562f13 100644 --- a/Source/audio/PerspectiveEffect.h +++ b/Source/audio/PerspectiveEffect.h @@ -30,7 +30,7 @@ public: std::make_shared(), std::vector{ new osci::EffectParameter("Perspective", "Controls the strength of the 3D perspective projection.", "perspectiveStrength", VERSION_HINT, 1.0, 0.0, 1.0), - new osci::EffectParameter("FOV", "Controls the camera's field of view in degrees. A lower field of view makes the image look more flat, and a higher field of view makes the image look more 3D.", "perspectiveFov", VERSION_HINT, 50.0, 5.0, 130.0), + new osci::EffectParameter("Field of View", "Controls the camera's field of view in degrees. A lower field of view makes the image look more flat, and a higher field of view makes the image look more 3D.", "perspectiveFov", VERSION_HINT, 50.0, 5.0, 130.0), } ); return eff; diff --git a/Source/components/EffectTypeGridComponent.cpp b/Source/components/EffectTypeGridComponent.cpp index 54e5dec..db10237 100644 --- a/Source/components/EffectTypeGridComponent.cpp +++ b/Source/components/EffectTypeGridComponent.cpp @@ -1,25 +1,24 @@ #include "EffectTypeGridComponent.h" +#include #include "../LookAndFeel.h" +#include "GridComponent.h" +#include "GridItemComponent.h" #include #include #include +// ===================== EffectTypeGridComponent (wrapper) ===================== + EffectTypeGridComponent::EffectTypeGridComponent(OscirenderAudioProcessor& processor) : audioProcessor(processor) { - // Setup scrollable viewport and content - addAndMakeVisible(viewport); - viewport.setViewedComponent(&content, false); - viewport.setScrollBarsShown(true, false); // vertical only - // Setup reusable bottom fade - initScrollFade(*this); - attachToViewport(viewport); - setupEffectItems(); + addAndMakeVisible(grid); setSize(400, 200); addAndMakeVisible(cancelButton); cancelButton.onClick = [this]() { if (onCanceled) onCanceled(); }; + setupEffectItems(); refreshDisabledStates(); } @@ -27,10 +26,8 @@ EffectTypeGridComponent::~EffectTypeGridComponent() = default; void EffectTypeGridComponent::setupEffectItems() { - // Clear existing items - effectItems.clear(); - content.removeAllChildren(); - + grid.clearItems(); + // Get effect types directly from the audio processor's toggleableEffects juce::SpinLock::ScopedLockType lock(audioProcessor.effectsLock); const int n = (int) audioProcessor.toggleableEffects.size(); @@ -52,11 +49,11 @@ void EffectTypeGridComponent::setupEffectItems() // Extract effect name from the effect juce::String effectName = effect->getName(); - // Create new item component - auto* item = new EffectTypeItemComponent(effectName, effect->getIcon(), effect->getId()); + // Create new generic item component + auto* item = new GridItemComponent(effectName, effect->getIcon(), effect->getId()); - // Set up callback to forward effect selection - item->onEffectSelected = [this](const juce::String& effectId) { + // Set up callback to forward selection + item->onItemSelected = [this](const juce::String& effectId) { if (onEffectSelected) onEffectSelected(effectId); }; @@ -74,8 +71,7 @@ void EffectTypeGridComponent::setupEffectItems() } }; - effectItems.add(item); - content.addAndMakeVisible(item); + grid.addItem(item); } } @@ -93,13 +89,11 @@ void EffectTypeGridComponent::refreshDisabledStates() selectedIds.insert(eff->getId().toStdString()); } } - for (auto* item : effectItems) { - const bool disable = selectedIds.find(item->getEffectId().toStdString()) != selectedIds.end(); + for (auto* item : grid.getItems()) { + const bool disable = selectedIds.find(item->getId().toStdString()) != selectedIds.end(); item->setEnabled(! disable); } cancelButton.setVisible(anySelected); - // Update fade visibility/layout in case scrollability changed - layoutScrollFade(viewport.getBounds(), true, 48); } void EffectTypeGridComponent::paint(juce::Graphics& g) @@ -112,94 +106,5 @@ void EffectTypeGridComponent::resized() auto bounds = getLocalBounds(); auto topBar = bounds.removeFromTop(30); cancelButton.setBounds(topBar.removeFromRight(80).reduced(4)); - viewport.setBounds(bounds); - auto contentArea = viewport.getLocalBounds(); - // Lock content width to viewport width to avoid horizontal scrolling - content.setSize(contentArea.getWidth(), content.getHeight()); - - // Create FlexBox for responsive grid layout within content - flexBox = juce::FlexBox(); - flexBox.flexWrap = juce::FlexBox::Wrap::wrap; - flexBox.justifyContent = juce::FlexBox::JustifyContent::spaceBetween; - flexBox.alignContent = juce::FlexBox::AlignContent::flexStart; - flexBox.flexDirection = juce::FlexBox::Direction::row; - - // Determine fixed per-item width for this viewport width - const int viewW = contentArea.getWidth(); - const int viewH = contentArea.getHeight(); - const int itemsPerRow = juce::jmax(1, viewW / MIN_ITEM_WIDTH); - const int fixedItemWidth = (itemsPerRow > 0 ? viewW / itemsPerRow : viewW); - - // Add each effect item with a fixed width, and pad the final row with placeholders so it's centered - const int total = effectItems.size(); - const int fullRows = (itemsPerRow > 0 ? total / itemsPerRow : 0); - const int remainder = (itemsPerRow > 0 ? total % itemsPerRow : 0); - - auto addItemFlex = [&](juce::Component* c) - { - flexBox.items.add(juce::FlexItem(*c) - .withMinWidth((float) fixedItemWidth) - .withMaxWidth((float) fixedItemWidth) - .withHeight((float) ITEM_HEIGHT) - .withFlex(1.0f) // keep existing flex behaviour; fixed max width holds size - .withMargin(juce::FlexItem::Margin(0))); - }; - - auto addPlaceholder = [&]() - { - // Placeholder occupies a slot visually but has no component; ensures last row is centered - juce::FlexItem placeholder((float) fixedItemWidth, (float) ITEM_HEIGHT); - placeholder.flexGrow = 1.0f; // match item flex for consistent spacing - placeholder.margin = juce::FlexItem::Margin(0); - flexBox.items.add(std::move(placeholder)); - }; - - int index = 0; - // Add complete rows - for (int r = 0; r < fullRows; ++r) - for (int c = 0; c < itemsPerRow; ++c) - addItemFlex(effectItems.getUnchecked(index++)); - - // Add last row centered with balanced placeholders - if (remainder > 0) - { - const int missing = itemsPerRow - remainder; - const int leftPad = missing / 2; - const int rightPad = missing - leftPad; - - for (int i = 0; i < leftPad; ++i) addPlaceholder(); - for (int i = 0; i < remainder; ++i) addItemFlex(effectItems.getUnchecked(index++)); - for (int i = 0; i < rightPad; ++i) addPlaceholder(); - } - - // Compute required content height - const int requiredHeight = calculateRequiredHeight(viewW); - - // If content is shorter than viewport, make content at least as tall as viewport - int yOffset = 0; - if (requiredHeight < viewH) { - content.setSize(viewW, viewH); - yOffset = (viewH - requiredHeight) / 2; - } else { - content.setSize(viewW, requiredHeight); - } - // Layout items within content at the computed offset - flexBox.performLayout(juce::Rectangle(0.0f, (float) yOffset, (float) viewW, (float) requiredHeight)); - - // Layout bottom scroll fade over the viewport area - layoutScrollFade(viewport.getBounds(), true, 48); -} - -int EffectTypeGridComponent::calculateRequiredHeight(int availableWidth) const -{ - if (effectItems.isEmpty()) - return ITEM_HEIGHT; - - // Calculate how many items can fit per row - int itemsPerRow = juce::jmax(1, availableWidth / MIN_ITEM_WIDTH); - - // Calculate number of rows needed - int numRows = (effectItems.size() + itemsPerRow - 1) / itemsPerRow; // Ceiling division - - return numRows * ITEM_HEIGHT; + grid.setBounds(bounds); } diff --git a/Source/components/EffectTypeGridComponent.h b/Source/components/EffectTypeGridComponent.h index cafdcf6..afada1c 100644 --- a/Source/components/EffectTypeGridComponent.h +++ b/Source/components/EffectTypeGridComponent.h @@ -1,10 +1,10 @@ #pragma once #include #include "../PluginProcessor.h" -#include "EffectTypeItemComponent.h" -#include "ScrollFadeMixin.h" +#include "GridComponent.h" -class EffectTypeGridComponent : public juce::Component, private ScrollFadeMixin +// Effect-specific wrapper that declares which items appear in the grid +class EffectTypeGridComponent : public juce::Component { public: EffectTypeGridComponent(OscirenderAudioProcessor& processor); @@ -13,23 +13,16 @@ public: void paint(juce::Graphics& g) override; void resized() override; - int calculateRequiredHeight(int availableWidth) const; std::function onEffectSelected; std::function onCanceled; // optional cancel handler void refreshDisabledStates(); // grey-out items that are already selected private: OscirenderAudioProcessor& audioProcessor; - juce::Viewport viewport; // scroll container - juce::Component content; // holds the grid items - juce::OwnedArray effectItems; - juce::FlexBox flexBox; + GridComponent grid; juce::TextButton cancelButton { "Cancel" }; - - static constexpr int ITEM_HEIGHT = 80; - static constexpr int MIN_ITEM_WIDTH = 180; - + void setupEffectItems(); - + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(EffectTypeGridComponent) }; diff --git a/Source/components/GridComponent.cpp b/Source/components/GridComponent.cpp new file mode 100644 index 0000000..e07db25 --- /dev/null +++ b/Source/components/GridComponent.cpp @@ -0,0 +1,131 @@ +#include "GridComponent.h" + +GridComponent::GridComponent() +{ + // Setup scrollable viewport and content + addAndMakeVisible(viewport); + viewport.setViewedComponent(&content, false); + viewport.setScrollBarsShown(true, false); // vertical only + // Setup reusable bottom fade + initScrollFade(*this); + attachToViewport(viewport); +} + +GridComponent::~GridComponent() = default; + +void GridComponent::clearItems() +{ + items.clear(); + content.removeAllChildren(); +} + +void GridComponent::addItem(GridItemComponent* item) +{ + items.add(item); + content.addAndMakeVisible(item); +} + +void GridComponent::paint(juce::Graphics& g) +{ + // transparent background +} + +void GridComponent::resized() +{ + auto bounds = getLocalBounds(); + viewport.setBounds(bounds); + auto contentArea = viewport.getLocalBounds(); + // Lock content width to viewport width to avoid horizontal scrolling + content.setSize(contentArea.getWidth(), content.getHeight()); + + // Create FlexBox for responsive grid layout within content + flexBox = juce::FlexBox(); + flexBox.flexWrap = juce::FlexBox::Wrap::wrap; + flexBox.justifyContent = juce::FlexBox::JustifyContent::spaceBetween; + flexBox.alignContent = juce::FlexBox::AlignContent::flexStart; + flexBox.flexDirection = juce::FlexBox::Direction::row; + + // Determine fixed per-item width for this viewport width + const int viewW = contentArea.getWidth(); + const int viewH = contentArea.getHeight(); + const int itemsPerRow = juce::jmax(1, viewW / MIN_ITEM_WIDTH); + const int fixedItemWidth = (itemsPerRow > 0 ? viewW / itemsPerRow : viewW); + + // Add each item with a fixed width, and pad the final row with placeholders so it's centered + const int total = items.size(); + const int fullRows = (itemsPerRow > 0 ? total / itemsPerRow : 0); + const int remainder = (itemsPerRow > 0 ? total % itemsPerRow : 0); + + auto addItemFlex = [&](juce::Component* c) + { + flexBox.items.add(juce::FlexItem(*c) + .withMinWidth((float) fixedItemWidth) + .withMaxWidth((float) fixedItemWidth) + .withHeight(80.0f) + .withFlex(1.0f) + .withMargin(juce::FlexItem::Margin(0))); + }; + + auto addPlaceholder = [&]() + { + // Placeholder occupies a slot visually but has no component; ensures last row is centered + juce::FlexItem placeholder((float) fixedItemWidth, 80.0f); + placeholder.flexGrow = 1.0f; // match item flex for consistent spacing + placeholder.margin = juce::FlexItem::Margin(0); + flexBox.items.add(std::move(placeholder)); + }; + + int index = 0; + // Add complete rows + for (int r = 0; r < fullRows; ++r) + for (int c = 0; c < itemsPerRow; ++c) + addItemFlex(items.getUnchecked(index++)); + + // Add last row centered with balanced placeholders + if (remainder > 0) + { + const int missing = itemsPerRow - remainder; + const int leftPad = missing / 2; + const int rightPad = missing - leftPad; + + for (int i = 0; i < leftPad; ++i) addPlaceholder(); + for (int i = 0; i < remainder; ++i) addItemFlex(items.getUnchecked(index++)); + for (int i = 0; i < rightPad; ++i) addPlaceholder(); + } + + // Compute required content height + const int requiredHeight = calculateRequiredHeight(viewW); + + // If content is shorter than viewport, make content at least as tall as viewport + int yOffset = 0; + if (requiredHeight < viewH) { + content.setSize(viewW, viewH); + yOffset = (viewH - requiredHeight) / 2; + } else { + content.setSize(viewW, requiredHeight); + } + // Layout items within content at the computed offset + flexBox.performLayout(juce::Rectangle(0.0f, (float) yOffset, (float) viewW, (float) requiredHeight)); + + // Layout bottom scroll fade over the viewport area + layoutScrollFadeIfNeeded(); +} + +int GridComponent::calculateRequiredHeight(int availableWidth) const +{ + if (items.isEmpty()) + return 80; // ITEM_HEIGHT + + // Calculate how many items can fit per row + int itemsPerRow = juce::jmax(1, availableWidth / MIN_ITEM_WIDTH); + + // Calculate number of rows needed + int numRows = (items.size() + itemsPerRow - 1) / itemsPerRow; // Ceiling division + + return numRows * 80; // ITEM_HEIGHT +} + +void GridComponent::layoutScrollFadeIfNeeded() +{ + layoutScrollFade(viewport.getBounds(), true, 48); +} diff --git a/Source/components/GridComponent.h b/Source/components/GridComponent.h new file mode 100644 index 0000000..a9dbbbc --- /dev/null +++ b/Source/components/GridComponent.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include "ScrollFadeMixin.h" +#include "GridItemComponent.h" + +// Generic grid component that owns and lays out GridItemComponent children +class GridComponent : public juce::Component, private ScrollFadeMixin +{ +public: + GridComponent(); + ~GridComponent() override; + + void paint(juce::Graphics& g) override; + void resized() override; + + void clearItems(); + void addItem(GridItemComponent* item); // takes ownership + juce::OwnedArray& getItems() { return items; } + int calculateRequiredHeight(int availableWidth) const; + +private: + juce::Viewport viewport; // scroll container + juce::Component content; // holds the grid items + juce::OwnedArray items; + juce::FlexBox flexBox; + + static constexpr int ITEM_HEIGHT = 80; + static constexpr int MIN_ITEM_WIDTH = 180; + + void layoutScrollFadeIfNeeded(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GridComponent) +}; diff --git a/Source/components/GridItemComponent.cpp b/Source/components/GridItemComponent.cpp new file mode 100644 index 0000000..4b7e5cb --- /dev/null +++ b/Source/components/GridItemComponent.cpp @@ -0,0 +1,123 @@ +#include "GridItemComponent.h" + +GridItemComponent::GridItemComponent(const juce::String& name, const juce::String& icon, const juce::String& id) + : itemName(name), itemId(id) +{ + juce::String iconSvg = icon; + if (icon.isEmpty()) { + // Default icon if none is provided + iconSvg = juce::String::createStringFromData(BinaryData::rotate_svg, BinaryData::rotate_svgSize); + } + iconButton = std::make_unique( + "gridItemIcon", + iconSvg, + juce::Colours::white.withAlpha(0.7f) + ); + + // Make the icon non-interactive since this is just a visual element + iconButton->setInterceptsMouseClicks(false, false); + addAndMakeVisible(*iconButton); +} + +GridItemComponent::~GridItemComponent() = default; + +void GridItemComponent::paint(juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat().reduced(10); + + // Get animation progress from inherited HoverAnimationMixin (disabled => no hover) + auto animationProgress = isEnabled() ? getAnimationProgress() : 0.0f; + + // Apply upward shift based on animation progress + auto yOffset = -animationProgress * HOVER_LIFT_AMOUNT; + bounds = bounds.translated(0, yOffset); + + // Draw drop shadow + if (animationProgress > 0.01f) { + juce::DropShadow shadow; + shadow.colour = juce::Colours::lime.withAlpha(animationProgress * 0.2f); + shadow.radius = 15 * animationProgress; + shadow.offset = juce::Point(0, 4); + + if (shadow.radius > 0) { + juce::Path shadowPath; + shadowPath.addRoundedRectangle(bounds.toFloat(), CORNER_RADIUS); + shadow.drawForPath(g, shadowPath); + } + } + + // Draw background with rounded corners - interpolate between normal and hover colors + juce::Colour normalBgColour = Colours::veryDark; + juce::Colour hoverBgColour = normalBgColour.brighter(0.05f); + juce::Colour bgColour = normalBgColour.interpolatedWith(hoverBgColour, animationProgress); + + g.setColour(bgColour); + g.fillRoundedRectangle(bounds.toFloat(), CORNER_RADIUS); + + // Draw colored outline + juce::Colour outlineColour = juce::Colour::fromRGB(160, 160, 160); + g.setColour(outlineColour.withAlpha(0.9f)); + g.drawRoundedRectangle(bounds.toFloat(), CORNER_RADIUS, 1.0f); + + // Create text area - now accounting for icon space on the left + auto textArea = bounds.reduced(8, 4); + textArea.removeFromLeft(28); // Remove space for icon (24px + 4px gap) + + g.setColour(juce::Colours::white); + g.setFont(juce::FontOptions(16.0f, juce::Font::plain)); + g.drawText(itemName, textArea, juce::Justification::centred, true); + + // If disabled, draw a dark transparent overlay over the rounded rect to simplify visuals + if (! isEnabled()) { + g.setColour(juce::Colours::black.withAlpha(0.35f)); + g.fillRoundedRectangle(bounds.toFloat(), CORNER_RADIUS); + } +} + +void GridItemComponent::resized() +{ + auto bounds = getLocalBounds().reduced(10); + + // Reserve space for the icon on the left + auto iconArea = bounds.removeFromLeft(60); // 24px for icon + iconArea = iconArea.withSizeKeepingCentre(40, 40); // Make icon 20x20px + + iconButton->setBounds(iconArea); + + // Get animation progress and calculate Y offset + auto animationProgress = isEnabled() ? getAnimationProgress() : 0.0f; + auto yOffset = -animationProgress * HOVER_LIFT_AMOUNT; + + iconButton->setTransform(juce::AffineTransform::translation(0, yOffset)); +} + +void GridItemComponent::mouseDown(const juce::MouseEvent& event) +{ + if (! isEnabled()) return; + // Extend base behavior to keep hover press animation + HoverAnimationMixin::mouseDown(event); + // Ensure any hover preview is cleared before permanently selecting/enabling the item + if (onHoverEnd) onHoverEnd(); + if (onItemSelected) { + onItemSelected(itemId); + } +} + +void GridItemComponent::mouseMove(const juce::MouseEvent& event) { + setMouseCursor(isEnabled() ? juce::MouseCursor::PointingHandCursor : juce::MouseCursor::NormalCursor); + juce::Desktop::getInstance().getMainMouseSource().forceMouseCursorUpdate(); +} + +void GridItemComponent::mouseEnter(const juce::MouseEvent& event) +{ + HoverAnimationMixin::mouseEnter(event); + if (isEnabled() && onHoverStart) + onHoverStart(itemId); +} + +void GridItemComponent::mouseExit(const juce::MouseEvent& event) +{ + HoverAnimationMixin::mouseExit(event); + if (onHoverEnd) + onHoverEnd(); +} diff --git a/Source/components/GridItemComponent.h b/Source/components/GridItemComponent.h new file mode 100644 index 0000000..8663fe9 --- /dev/null +++ b/Source/components/GridItemComponent.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include "../LookAndFeel.h" +#include "HoverAnimationMixin.h" +#include "SvgButton.h" + +// Generic grid item with name, icon, and id +class GridItemComponent : public HoverAnimationMixin +{ +public: + GridItemComponent(const juce::String& name, const juce::String& icon, const juce::String& id); + ~GridItemComponent() override; + + void paint(juce::Graphics& g) override; + void resized() override; + void mouseDown(const juce::MouseEvent& event) override; + void mouseMove(const juce::MouseEvent& event) override; + void mouseEnter(const juce::MouseEvent& event) override; + void mouseExit(const juce::MouseEvent& event) override; + + const juce::String& getId() const { return itemId; } + const juce::String& getName() const { return itemName; } + + std::function onItemSelected; + std::function onHoverStart; + std::function onHoverEnd; + +private: + juce::String itemName; + juce::String itemId; + + // Icon for the item + std::unique_ptr iconButton; + + static constexpr int CORNER_RADIUS = 8; + static constexpr float HOVER_LIFT_AMOUNT = 2.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(GridItemComponent) +}; diff --git a/osci-render.jucer b/osci-render.jucer index eef1e34..11df168 100644 --- a/osci-render.jucer +++ b/osci-render.jucer @@ -197,6 +197,13 @@ file="Source/components/EffectTypeItemComponent.h"/> + + + +