/* ============================================================================== LookAndFeel.cpp Created: 19 Sep 2023 4:48:33pm Author: binya ============================================================================== */ #include "CustomLookAndFeel.h" #include "Components/Buttons.h" #include "Sampler/CustomSamplerVoice.h" CustomLookAndFeel::CustomLookAndFeel() { setTheme(defaultTheme); setUsingNativeAlertWindows(true); } void CustomLookAndFeel::setTheme(const Colors& theme) { colors = theme; setColour(juce::Label::textColourId, colors.dark); setColour(juce::Label::textWhenEditingColourId, colors.dark); setColour(juce::Label::outlineWhenEditingColourId, juce::Colours::transparentWhite); setColour(juce::TextEditor::highlightColourId, colors.slate.withAlpha(0.15f)); setColour(juce::TextEditor::highlightedTextColourId, colors.dark); setColour(juce::CaretComponent::caretColourId, colors.dark); setColour(juce::ComboBox::textColourId, colors.dark); setColour(juce::Slider::thumbColourId, colors.highlight); setColour(juce::ResizableWindow::backgroundColourId, colors.foreground); setColour(juce::ListBox::backgroundColourId, colors.foreground.withAlpha(0.f)); setColour(juce::ListBox::outlineColourId, colors.dark.withAlpha(0.f)); setColour(juce::ListBox::textColourId, colors.dark); setColour(juce::ScrollBar::thumbColourId, colors.dark); setColour(Colors::backgroundColorId, colors.background); setColour(Colors::painterColorId, colors.dark); } juce::Slider::SliderLayout CustomLookAndFeel::getSliderLayout(juce::Slider& slider) { auto bounds = slider.getLocalBounds().toFloat(); bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding); float textSize = bounds.getWidth() * Layout::rotaryTextSize; juce::Slider::SliderLayout layout; layout.sliderBounds = slider.getLocalBounds(); if (slider.getTextBoxPosition() != juce::Slider::NoTextBox) layout.textBoxBounds = juce::Rectangle(bounds.getX(), (bounds.getHeight() - textSize / 3.5f) / 2.f, bounds.getWidth(), textSize).reduced(bounds.getWidth() * 0.1f, 0.f).getLargestIntegerWithin(); return layout; } juce::Label* CustomLookAndFeel::createSliderTextBox(juce::Slider& slider) { using namespace juce; auto bounds = slider.getLocalBounds().toFloat(); bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding); auto label = new CustomLabel(); label->setColour(TextEditor::highlightColourId, Colours::transparentWhite); label->setJustificationType(Justification::centredBottom); label->setFont(getInriaSansBold().withHeight(bounds.getWidth() * Layout::rotaryTextSize)); label->setHasFocusOutline(false); label->setKeyboardType(TextInputTarget::decimalKeyboard); label->setEditable(false, true, false); label->setEnabledMouseCursor(MouseCursor::IBeamCursor); label->onEditorShow = [label] { auto editor = label->getCurrentTextEditor(); editor->setJustification(Justification::centredBottom); editor->setIndents(0, 0); editor->moveCaretToEnd(); editor->selectAll(); // Some compensation to keep the text in a constant position auto editorBounds = editor->getBounds().toFloat(); float offsetAmount = editorBounds.getHeight() * 0.125f; editorBounds.setY(editorBounds.getY() - offsetAmount); editorBounds.translate(offsetAmount, 0); editorBounds.expand(0, offsetAmount); editor->setBounds(editorBounds.toNearestInt()); }; return label; } void CustomLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPosProportional, float, float, juce::Slider& slider) { using namespace juce; auto bounds = Rectangle(x, y, width, height).toFloat(); bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding / (1 + Layout::rotaryPadding)); bounds.setHeight(bounds.getWidth() * Layout::rotaryHeightRatio); auto radius = bounds.getWidth() / 2.f; auto lineWidth = radius * 0.2f; constexpr float rotaryStartAngle = MathConstants::pi * (1 + 5.f / 26.f); constexpr float rotaryEndAngle = rotaryStartAngle + MathConstants::pi * (2.f - 5.f / 13.f); auto toAngle = rotaryStartAngle + sliderPosProportional * (rotaryEndAngle - rotaryStartAngle); // Draw the background arc const float lineOffset = lineWidth * 0.5f; Path backgroundArc; backgroundArc.addCentredArc(bounds.getCentreX(), bounds.getY() + radius + lineOffset / 2.f, radius - lineOffset, radius - lineOffset / 2.f, 0.0f, rotaryStartAngle, rotaryEndAngle, true); g.setColour(colors.dark); g.strokePath(backgroundArc, PathStrokeType(lineWidth)); // Draw the value arc float baseAngle = rotaryStartAngle; if (approximatelyEqual(slider.getRange().getStart(), -slider.getRange().getEnd())) baseAngle = MathConstants::twoPi; Path valueArc; valueArc.addCentredArc(bounds.getCentreX(), bounds.getY() + radius + lineOffset / 2.f, radius - lineOffset, radius - lineOffset / 2.f, 0.0f, baseAngle, toAngle, true); g.setColour(colors.highlight); g.strokePath(valueArc, PathStrokeType(lineWidth)); // Draw the unit label g.setColour(colors.dark); if (slider.getProperties().contains(ComponentProps::ROTARY_ICON)) { auto iconPath = dynamic_cast(slider.getProperties()[ComponentProps::ROTARY_ICON].getObject())->path; g.fillPath(iconPath, iconPath.getTransformToScaleToFit(Rectangle{ bounds.getX() + 0.025f * bounds.getWidth(), bounds.getY() + 0.78f * bounds.getHeight(), bounds.getWidth(), 12.f * bounds.getWidth() / Layout::standardRotarySize}, true)); } else if (slider.getTextBoxPosition() != Slider::NoTextBox) { var unit = slider.getProperties().getWithDefault(ComponentProps::ROTARY_UNIT, ""); if (slider.getValue() >= 1000) unit = slider.getProperties().getWithDefault(ComponentProps::ROTARY_GREATER_UNIT, unit); g.setFont(getInriaSansBold().withHeight(20.f * bounds.getWidth() / Layout::standardRotarySize)); g.drawText(unit, Rectangle{ bounds.getX(), bounds.getY() + 0.78f * bounds.getHeight(), bounds.getWidth(), g.getCurrentFont().getAscent() }, Justification::centredBottom); } // Draw % if there is no textbox if (slider.getTextBoxPosition() == Slider::NoTextBox) { g.setFont(getInriaSansBold().withHeight(0.68f * bounds.getWidth())); bounds.removeFromTop(bounds.getHeight() * 0.08f); g.drawText("%", bounds, Justification::centred); } if (!slider.isEnabled()) { g.setColour(slider.findColour(colors.backgroundColorId, true).withAlpha(0.5f)); g.fillRect(slider.getLocalBounds()); } } void CustomLookAndFeel::drawLabel(juce::Graphics& g, juce::Label& label) { using namespace juce; if (!label.isBeingEdited()) { auto textArea = label.getLocalBounds(); // Draw the background (if the colors are set) float cornerSize = textArea.getHeight() * 0.2f; float borderSize = textArea.getHeight() * 0.05f; g.setColour(label.findColour(Label::backgroundColourId)); g.fillRoundedRectangle(textArea.toFloat().reduced(borderSize * 0.33f), cornerSize); g.setColour(label.findColour(Label::outlineColourId)); g.drawRoundedRectangle(textArea.toFloat().reduced(borderSize * 0.48f), cornerSize, borderSize); // Draw the text const Font font(getLabelFont(label)); g.setColour(label.findColour(Label::textColourId).withMultipliedAlpha(label.isEnabled() ? 1.f : 0.5f)); g.setFont(font); g.drawText(label.getText(), textArea, label.getJustificationType(), label.getProperties().contains(ComponentProps::LABEL_ELLIPSES)); } } juce::Button* CustomLookAndFeel::createFilenameComponentBrowseButton(const juce::String& /*text*/) { return new CustomShapeButton(colors.dark, getOutlineFromSVG(BinaryData::IconAdd_svg)); } void CustomLookAndFeel::layoutFilenameComponent(juce::FilenameComponent& filenameComp, juce::ComboBox* filenameBox, juce::Button* browseButton) { if (browseButton == nullptr || filenameBox == nullptr) return; auto compBounds = filenameComp.getLocalBounds().toFloat(); auto addButtonBounds = compBounds.removeFromRight(filenameComp.getHeight() * 0.945f).reduced(0.158f * compBounds.getHeight(), 0.18f * compBounds.getHeight()); browseButton->setBounds(addButtonBounds.toNearestInt()); filenameBox->setBounds(compBounds.toNearestInt()); } juce::Font CustomLookAndFeel::getComboBoxFont(juce::ComboBox& comboBox) { return getInter().withHeight(comboBox.getHeight() * 0.73f); } juce::Label* CustomLookAndFeel::createComboBoxTextBox(juce::ComboBox& comboBox) { using namespace juce; auto bounds = comboBox.getLocalBounds().toFloat(); auto label = new CustomLabel(); label->setFont(getInter().withHeight(bounds.getWidth() * Layout::rotaryTextSize)); label->setJustificationType(Justification::centredLeft); label->setHasFocusOutline(false); label->setKeyboardType(TextInputTarget::decimalKeyboard); label->setEditable(false, true, false); label->getProperties().set(ComponentProps::LABEL_ELLIPSES, true); label->setEnabledMouseCursor(MouseCursor::IBeamCursor); label->onEditorShow = [label] { auto editor = label->getCurrentTextEditor(); editor->setJustification(Justification::centredLeft); editor->setIndents(0, 0); editor->moveCaretToEnd(); // Some compensation to keep the text in a constant position auto editorBounds = editor->getBounds().toFloat(); float offset = editorBounds.getHeight() * 0.125f; editorBounds.translate(-1, 0); editorBounds.expand(0, offset); editor->setBounds(editorBounds.toNearestInt()); }; return label; } void CustomLookAndFeel::positionComboBoxText(juce::ComboBox& box, juce::Label& label) { auto bounds = box.getLocalBounds().toFloat(); bounds.removeFromRight(Layout::expandWidth * box.getHeight()); label.setBounds(bounds.toNearestInt()); label.setFont(getComboBoxFont(box)); } void CustomLookAndFeel::drawComboBox(juce::Graphics& g, int /*width*/, int /*height*/, bool isButtonDown, int /*buttonX*/, int /*buttonY*/, int /*buttonW*/, int /*buttonH*/, juce::ComboBox& box) { using namespace juce; auto bounds = box.getLocalBounds().toFloat(); auto expandBounds = bounds.removeFromRight(Layout::expandWidth * bounds.getHeight()).reduced(0.272f * bounds.getHeight(), 0.321f * bounds.getHeight()); if (isButtonDown) expandBounds.reduce(expandBounds.getHeight() * 0.05f, expandBounds.getHeight() * 0.05f); Path path; path.startNewSubPath(expandBounds.getTopLeft()); path.lineTo(expandBounds.getCentreX(), expandBounds.getBottom()); path.lineTo(expandBounds.getTopRight()); g.setColour(colors.dark); g.strokePath(path, PathStrokeType(0.1f * box.getHeight(), PathStrokeType::curved, PathStrokeType::rounded)); if (!box.isEnabled()) { g.setColour(box.findColour(colors.backgroundColorId, true).withAlpha(0.5f)); g.fillRect(box.getLocalBounds()); } } void CustomLookAndFeel::drawComboBoxTextWhenNothingSelected(juce::Graphics& g, juce::ComboBox& box, juce::Label& label) { auto textArea = getLabelBorderSize(label).subtractedFrom(label.getLocalBounds()); auto font = label.getLookAndFeel().getLabelFont(label); g.setColour(findColour(juce::ComboBox::textColourId)); g.setFont(font); g.drawText(box.getTextWhenNothingSelected(), textArea, label.getJustificationType()); if (!box.isEnabled()) { g.setColour(box.findColour(colors.backgroundColorId, true).withAlpha(0.5f)); g.fillRect(box.getLocalBounds()); } } juce::PopupMenu::Options CustomLookAndFeel::getOptionsForComboBoxPopupMenu(juce::ComboBox& box, juce::Label& label) { return juce::PopupMenu::Options().withTargetComponent(&box) .withPreferredPopupDirection(juce::PopupMenu::Options::PopupDirection::downwards) .withItemThatMustBeVisible(box.getSelectedId()) .withInitiallySelectedItem(box.getSelectedId()) .withMinimumWidth(box.getWidth()) .withMaximumNumColumns(1) .withStandardItemHeight(label.getHeight()); } juce::Font CustomLookAndFeel::getPopupMenuFont() { return getInter().withHeight(17.f); } void CustomLookAndFeel::drawPopupMenuBackground(juce::Graphics& graphics, int, int) { graphics.fillAll(colors.slate); } void CustomLookAndFeel::drawPopupMenuItem(juce::Graphics& g, const juce::Rectangle& area, bool /*isSeparator*/, bool isActive, bool isHighlighted, bool isTicked, bool /*hasSubMenu*/, const juce::String& text, const juce::String& /*shortcutKeyText*/, const juce::Drawable* /*icon*/, const Colour* /*textColour*/) { using namespace juce; auto bounds = area.toFloat(); if (isHighlighted && isActive) { g.setColour(colors.darkerSlate); g.fillRect(bounds); } float padding = area.getWidth() * 0.02f; bounds.reduce(padding, 0); auto font = getPopupMenuFont(); auto maxFontHeight = bounds.getHeight() * 0.77f; if (font.getHeight() > maxFontHeight) font.setHeight(maxFontHeight); g.setColour(colors.light); g.setFont(font); auto iconArea = bounds.removeFromLeft(bounds.getHeight()).toFloat().reduced(bounds.getHeight() * 0.29f); if (isTicked) { auto tick = getTickShape(1.0f); g.strokePath(tick, PathStrokeType(iconArea.getWidth() * 0.18f, PathStrokeType::curved, PathStrokeType::rounded), tick.getTransformToScaleToFit(iconArea, true)); } bounds.removeFromRight(3); g.drawText(text, bounds, Justification::centredLeft, true); } void CustomLookAndFeel::drawPopupMenuUpDownArrow(juce::Graphics& g, int width, int height, bool isScrollUpArrow) { using namespace juce; auto hw = float(width * 0.5f); auto arrowW = float(height * 0.3f); auto y1 = float(height) * (isScrollUpArrow ? 0.6f : 0.3f); auto y2 = float(height) * (isScrollUpArrow ? 0.3f : 0.6f); Path p; p.addTriangle(hw - arrowW, y1, hw + arrowW, y1, hw, y2); g.setColour(colors.dark); g.fillPath(p); } juce::Path CustomLookAndFeel::getTickShape(float height) { juce::Path tick; tick.startNewSubPath(height, 0.f); tick.lineTo(height * 0.4f, height); tick.lineTo(0.f, height * 0.5f); return tick; } void CustomLookAndFeel::drawTickBox(juce::Graphics& g, juce::Component& component, float /*x*/, float /*y*/, float /*w*/, float /*h*/, bool ticked, bool /*isEnabled*/, bool /*shouldDrawButtonAsHighlighted*/, bool /*shouldDrawButtonAsDown*/) { using namespace juce; auto bounds = component.getLocalBounds().toFloat(); auto size = jmin(bounds.getWidth(), bounds.getHeight()); bounds.reduce((bounds.getWidth() - size) / 2.f, (bounds.getHeight() - size) / 2.f); float lineThickness = 0.093f * size; if (ticked) { g.setColour(colors.highlight); g.fillRect(bounds.reduced(lineThickness * 0.75f)); } g.setColour(colors.dark); g.drawRoundedRectangle(bounds.reduced(lineThickness * 0.66f), 0.14f * size, lineThickness); if (!component.isEnabled()) { g.setColour(component.findColour(colors.backgroundColorId, true).withAlpha(0.5f)); g.fillRect(component.getLocalBounds()); } } //============================================================================== template void EnvelopeSliderLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float, float, float, juce::Slider& slider) { using namespace juce; auto bounds = Rectangle(x, y, width, height).toFloat(); bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding); float exp = float(slider.getValue()); Path curve; for (int i = 0; i < width; i++) { float xPos = float(i) / width; float yPos = CustomSamplerVoice::exponentialCurve(exp, xPos); if constexpr (Direction == EnvelopeSlider::release) yPos = 1 - yPos; if (i == 0) curve.startNewSubPath(float(i), bounds.getHeight() * (1 - yPos)); else curve.lineTo(float(i), bounds.getHeight() * (1 - yPos)); } g.setColour(colors.dark.withAlpha(slider.isEnabled() ? 1.f : 0.5f)); g.strokePath(curve, PathStrokeType(0.08f * width, PathStrokeType::curved, PathStrokeType::rounded), curve.getTransformToScaleToFit(bounds, false)); slider.setMouseCursor(slider.isEnabled() ? MouseCursor::UpDownResizeCursor : MouseCursor::NormalCursor); } void CustomLookAndFeel::drawCornerResizer(juce::Graphics& g, int w, int h, bool isMouseOver, bool isMouseDragging) { auto lineThickness = juce::jmin(float(w), float(h)) * 0.075f; auto d = 0.6f; for (auto i = 0; i < 1 + int(isMouseOver || isMouseDragging); i++) { g.setColour(colors.slate); g.drawLine(float(w) * d, float(h) + 1.0f, float(w) + 1.0f, float(h) * d, lineThickness); g.setColour(colors.darkerSlate); g.drawLine(float(w) * d + lineThickness, float(h) + 1.0f, float(w) + 1.0f, float(h) * d + lineThickness, lineThickness); d -= 0.3f; } } //============================================================================== juce::Slider::SliderLayout VolumeSliderLookAndFeel::getSliderLayout(juce::Slider& slider) { auto borderSize = slider.getWidth() * 0.020f; juce::Slider::SliderLayout layout; layout.sliderBounds = slider.getLocalBounds(); layout.sliderBounds.removeFromLeft(int(std::round(slider.getWidth() * 0.12f))); layout.sliderBounds.removeFromRight(int(std::round(borderSize + slider.getWidth() * 0.09f))); layout.textBoxBounds = slider.getLocalBounds().removeFromTop(int(std::round(0.21f * slider.getWidth()))); layout.textBoxBounds.removeFromLeft(int(std::round(slider.getWidth() * 0.1f))); layout.textBoxBounds.removeFromRight(int(std::round(slider.getWidth() * 0.25f))); return layout; } juce::Label* VolumeSliderLookAndFeel::createSliderTextBox(juce::Slider& slider) { auto label = CustomLookAndFeel::createSliderTextBox(slider); label->setFont(getInriaSansBold().withHeight(slider.getWidth() * 0.225f)); label->setJustificationType(juce::Justification::bottomRight); auto superOnEditorShow = label->onEditorShow; label->onEditorShow = [label, superOnEditorShow] { superOnEditorShow(); auto editor = label->getCurrentTextEditor(); auto editorBounds = editor->getBounds(); editorBounds.translate(3, 0); editor->setBounds(editorBounds); editor->setJustification(juce::Justification::bottomRight); }; return label; } void VolumeSliderLookAndFeel::drawLinearSlider(juce::Graphics& g, int /*x*/, int /*y*/, int /*width*/, int /*height*/, float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) { using namespace juce; auto bounds = slider.getBounds().withZeroOrigin().toFloat(); auto borderSize = bounds.getWidth() * 0.020f; bounds = bounds.reduced(borderSize); // Get thumb dimensions auto thumbWidth = bounds.getWidth() * 0.203f; auto thumbHeight = thumbWidth * 0.5f; auto thumbRound = bounds.getWidth() * 0.032f; auto thumb = Rectangle(sliderPos - thumbWidth / 2.f, bounds.getHeight() - thumbHeight, thumbWidth, thumbHeight); // Draw fader shape Path sliderShape; float bottom = bounds.getHeight() - thumbHeight / 2.f; float right = bounds.getRight() - bounds.getWidth() * 0.08f; sliderShape.addTriangle(bounds.getX(), bottom, right, bottom, right, bottom - bounds.getWidth() * 0.4f); sliderShape = sliderShape.createPathWithRoundedCorners(2 * borderSize); g.setColour(colors.highlight); float scale = sliderPos / (right - bounds.getX()) * 0.985f; g.fillPath(sliderShape, AffineTransform::scale(scale, scale * 0.98f, bounds.getX(), bottom)); g.setColour(colors.dark); g.strokePath(sliderShape, PathStrokeType(borderSize, PathStrokeType::JointStyle::curved)); if (!slider.isEnabled()) { g.setColour(slider.findColour(colors.backgroundColorId, true).withAlpha(0.5f)); g.fillRect(slider.getLocalBounds()); } } //============================================================================== const juce::Font& getInriaSans() { static juce::Font inriaSans{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InriaSansRegular_ttf, BinaryData::InriaSansRegular_ttfSize)) }; return inriaSans; } const juce::Font& getInriaSansBold() { static juce::Font inriaSansBold{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InriaSansBold_ttf, BinaryData::InriaSansBold_ttfSize)) }; return inriaSansBold; } const juce::Font& getInter() { static juce::Font inter{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InterRegular_ttf, BinaryData::InterRegular_ttfSize)) }; return inter; } const juce::Font& getInterBold() { static juce::Font interBold{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InterBold_ttf, BinaryData::InterBold_ttfSize)) }; return interBold; } juce::Path getOutlineFromSVG(const char* data) { auto xml = juce::parseXML(data); jassert(xml != nullptr); return juce::Drawable::createFromSVG(*xml)->getOutlineAsPath(); }