| name | juce-visage |
| description | Guide for integrating the Visage GPU-accelerated UI framework with JUCE audio plugins on macOS and iOS/iPadOS. Covers Metal view embedding, event bridging, focus management, keyboard handling in DAW hosts, popups/modals/dropdowns, memory management, destruction ordering, native standalone appearance, required Visage patches, iOS touch event handling, safe area insets, and comprehensive Visage API reference (Canvas, Frame, Widget, Theme, Dimension, PostEffect, Event system). Patterns derived from production plugin development. Use this skill whenever building or debugging JUCE+Visage plugin UI, even if the user doesn't explicitly mention "Visage" — trigger on mentions of Metal rendering in JUCE, GPU UI in audio plugins, embedded MTKView, or bridge layers between JUCE and a GPU framework. |
JUCE + Visage Integration Guide
This skill covers how to build a JUCE audio plugin (AU/VST3/Standalone) or iOS/iPadOS app that uses Visage for its UI.
Scope: macOS and iOS/iPadOS (Metal rendering, NSView/UIView embedding, event bridging). On macOS, the bridge forwards mouse events from JUCE to Visage. On iOS, Visage's VisageMetalView handles touch events natively — the bridge skips mouse forwarding entirely.
Tested with: Visage (VitalAudio fork, included directly in repo), JUCE 7/8, Logic Pro, Ableton Live, Reaper.
When to Use This Skill
Use when:
- Starting a new JUCE plugin project that will use Visage for its UI
- Adding Visage UI to an existing JUCE plugin
- Building a JUCE iOS/iPadOS app with Visage GPU-accelerated UI
- Debugging rendering, keyboard, mouse, touch, or focus issues in a JUCE+Visage plugin/app
- Building modals, overlays, dropdowns, or secondary windows with Visage inside JUCE
- Porting Visage standalone patterns into a DAW plugin context
- Troubleshooting AU/VST3 validation timeouts or scanner failures
- Making a standalone JUCE+Visage app look native on macOS
- Adapting macOS Visage UI for iOS touch interaction and safe areas
Do NOT use when:
- Building a pure JUCE UI (no Visage)
- Building a standalone Visage app without JUCE
- Working on audio processing, DSP, or MIDI logic unrelated to the UI layer
Per-Project Notes
This skill provides generic patterns. Each project should maintain a docs/juce-visage-notes.md file with project-specific details — bridge layer file paths, applied patches, destruction sequences, popup/modal/dropdown inventories, JUCE exceptions, technical debt, and learnings.
Claude's behavior with per-project notes:
- On Visage tasks: Look for
docs/juce-visage-notes.md in the project root. Read it alongside this skill.
- If missing: Offer to create it from the template in
references/troubleshooting.md (see "Maintaining Per-Project Notes").
- After solving issues: Update the per-project file with new learnings, new popup/modal instances, or newly discovered patterns.
Architecture Overview
JUCE owns the plugin window (the AudioProcessorEditor and its native peer). Visage owns a Metal-based render loop via an MTKView. The two frameworks have no built-in awareness of each other — every event (mouse, keyboard, clipboard, focus, resize) must be manually bridged.
JUCE AudioProcessorEditor
└── JuceVisageBridge (juce::Component + juce::Timer)
├── visage::ApplicationWindow (embedded MTKView as child of JUCE peer NSView)
│ └── VisageAppView (MTKView, Metal render loop)
├── visage::Frame* rootFrame (top of the Visage frame tree)
│ └── [child frames: buttons, text editors, panels...]
├── visage::FrameEventHandler (callbacks into JUCE: clipboard, focus, cursor, redraw)
└── Focus/event state tracking
The ApplicationWindow is created in plugin mode: no NSWindow is created. Instead, the VisageAppView (an MTKView) is added as a subview of the JUCE peer's NSView via [parentView addSubview:view_].
Quick Start: Minimal Integration
1. Build System Setup (CMakeLists.txt)
Add Visage as a subdirectory and link it to your plugin target:
# Add Visage
add_subdirectory(external/visage)
# Link to your JUCE plugin target
# Common target names: VisageApp, VisageUi, VisageGraphics, VisageWindowing, VisageWidgets, VisageUtils
# Upstream may expose a single 'visage' target instead.
target_link_libraries(YourPlugin
PRIVATE
VisageApp
VisageUi
VisageWidgets
VisageGraphics
VisageWindowing
VisageUtils
juce::juce_audio_processors
juce::juce_gui_basics
)
# Include paths for Visage headers
target_include_directories(YourPlugin PRIVATE
${CMAKE_SOURCE_DIR}/external/visage
${CMAKE_SOURCE_DIR}/external/visage/visage_ui
${CMAKE_SOURCE_DIR}/external/visage/visage_graphics
${CMAKE_SOURCE_DIR}/external/visage/visage_windowing
${CMAKE_SOURCE_DIR}/external/visage/visage_app
${CMAKE_SOURCE_DIR}/external/visage/visage_widgets
${CMAKE_SOURCE_DIR}/external/visage/visage_utils
)
Include Visage directly in the repository (not as a git submodule) so you can maintain patches.
2. Bridge Component Class
Create a JUCE component that hosts the Visage window:
class JuceVisageBridge : public juce::Component,
public juce::Timer,
public juce::ComponentListener {
public:
JuceVisageBridge() {
setOpaque(true);
setWantsKeyboardFocus(false);
setInterceptsMouseClicks(true, true);
setMouseClickGrabsKeyboardFocus(false);
eventHandler.request_keyboard_focus = [this](visage::Frame* child) {
setFocusedChild(child);
};
eventHandler.read_clipboard_text = []() -> std::string {
return juce::SystemClipboard::getTextFromClipboard().toStdString();
};
eventHandler.set_clipboard_text = [](const std::string& text) {
juce::SystemClipboard::copyTextToClipboard(juce::String(text));
};
eventHandler.set_cursor_style = [this](visage::MouseCursor cursor) {
};
eventHandler.request_redraw = [this](visage::Frame* frame) {
repaint();
};
}
void setRootFrame(visage::Frame* frame) {
rootFrame = frame;
if (rootFrame) rootFrame->setEventHandler(&eventHandler);
}
void createEmbeddedWindow() {
if (visageWindow || !isShowing() || !getPeer()) return;
auto* peer = getPeer();
void* parentHandle = peer->getNativeHandle();
auto bounds = getLocalBounds();
if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) return;
visageWindow = std::make_unique<visage::ApplicationWindow>();
float scale = juce::Desktop::getInstance().getDisplays()
.getDisplayForPoint(getScreenPosition())->scale;
visageWindow->setDpiScale(scale);
int w = bounds.getWidth();
int h = bounds.getHeight();
visageWindow->show(
visage::Dimension::logicalPixels(w),
visage::Dimension::logicalPixels(h),
parentHandle
);
visageWindow->setBounds(0, 0, w, h);
if (rootFrame) {
rootFrame->init();
visageWindow->addChild(rootFrame);
rootFrame->setBounds(0, 0, w, h);
}
visageWindow->drawWindow();
}
private:
std::unique_ptr<visage::ApplicationWindow> visageWindow;
visage::Frame* rootFrame = nullptr;
visage::Frame* focusedChild = nullptr;
visage::FrameEventHandler eventHandler;
};
3. Plugin Editor
class MyPluginEditor : public juce::AudioProcessorEditor,
public juce::Timer {
public:
MyPluginEditor(MyProcessor& p) : AudioProcessorEditor(p) {
setSize(800, 600);
startTimer(10);
}
~MyPluginEditor() override {
stopTimer();
if (bridge) bridge->shutdownRendering();
if (rootFrame) rootFrame->removeAllChildren();
rootFrame.reset();
bridge.reset();
}
void timerCallback() override {
if (!rootFrame && getLocalBounds().getWidth() > 0) {
stopTimer();
createVisageUI();
startTimer(33);
}
}
void createVisageUI() {
rootFrame = std::make_unique<visage::Frame>();
if (auto* window = findParentComponentOfClass<juce::DocumentWindow>()) {
window->setUsingNativeTitleBar(true);
setSize(800, 600);
}
bridge = std::make_unique<JuceVisageBridge>();
addAndMakeVisible(*bridge);
bridge->setRootFrame(rootFrame.get());
}
void resized() override {
if (bridge) bridge->setBounds(getLocalBounds());
if (rootFrame) {
rootFrame->setBounds(0, 0, getWidth(), getHeight());
layoutChildren();
}
}
void layoutChildren() {
}
private:
std::unique_ptr<JuceVisageBridge> bridge;
std::unique_ptr<visage::Frame> rootFrame;
};
Critical Integration Patterns
Window Creation Timing
Never create the Visage window in the constructor. JUCE may call the constructor before the native peer exists or before the component has valid bounds. Always defer:
MyEditor() { createVisageUI(); }
MyEditor() { startTimer(10); }
void timerCallback() {
if (isShowing() && getPeer() && getWidth() > 0) {
createVisageUI();
}
}
For secondary windows (DocumentWindow), defer further — use callAfterDelay(50, ...) if the native handle is not yet available, as plugin hosts may need extra time to set up the peer.
Destruction Order
The Metal display link can fire at up to 120 Hz on ProMotion displays (60 Hz with the FPS cap patch applied) and holds raw pointers to Visage frames. If you free frames while the display link is running, you get use-after-free crashes. Always:
~MyPluginEditor() {
bridge->shutdownRendering();
bridge->setRootFrame(nullptr);
rootFrame->removeAllChildren();
rootFrame.reset();
bridge.reset();
}
See references/troubleshooting.md for the full 11-step destruction sequence and memory management patterns.
Key Concepts
Memory & Lifetime Management
All frames should be owned via std::unique_ptr<visage::Frame>. The bridge holds a non-owning pointer. Modals and popups need defensive patterns (isClosing_ guard, weak-pointer pattern, active registry with mutex) because they can be dismissed asynchronously.
Details: references/troubleshooting.md — Memory Management, Modal/Popup Lifetime Safety, Dropdown Cleanup
Event Bridging & Focus (macOS)
JUCE and Visage use different key code and modifier systems. The critical conversions: Cmd must map to kModifierCmd (not kModifierMacCtrl), and modifier+letter combos need explicit KeyCode mapping. Mouse events use a "mouse-down frame capture" pattern. Focus requires dynamic toggling of setWantsKeyboardFocus().
Details: references/platform-macos.md — Event Bridging, Focus Management, Plugin-Specific Fixes
iOS Differences
On iOS, VisageMetalView handles touches natively — the bridge must NOT forward JUCE mouse events (causes double events). Guard mouse overrides with #if !JUCE_IOS. Always apply safe area insets. Minimum 44pt touch targets.
Details: references/platform-ios.md
Popups, Modals & Dropdowns
All in-plugin UI should render inside the Visage GPU layer — no JUCE native popups. Four systems available: visage::PopupMenu (context menus), VisageDropdownComboBox (inline selectors), VisageModalDialog (full-screen modals), VisageOverlayBase (animated overlays with blur). Pick one overlay system per project.
Details: references/ui-patterns.md — Popups, Dropdowns, and Modals; Z-Order Summary
Visage API
Comprehensive reference for Frame, Canvas, Color/Brush/Theme, Font, PostEffect, Widget, Event, and Dimension systems. Also includes JUCE-to-Visage migration tables and build system (CMake, font embedding, FetchContent).
Details: references/visage-api.md
Reference Files
Read these as needed based on the task at hand:
| File | When to Read | Content |
|---|
references/visage-api.md | Building UI, drawing, theming, using widgets, migrating from JUCE | Frame, Canvas, Color/Brush/Theme, Font, PostEffect, Widget, Event, Dimension APIs + JUCE migration tables + CMake build system |
references/platform-macos.md | macOS standalone appearance, DAW plugin keyboard issues, applying Visage patches | Native title bar, menu bar, keyboard shortcuts, event bridging, focus management, all plugin-specific fixes, patches checklist |
references/platform-ios.md | iOS/iPadOS integration, touch events, safe areas | Bridge simplification, DPI, safe area insets, touch guidelines, platform limitations |
references/ui-patterns.md | Building popups, dropdowns, modals, secondary windows, text editors | Frame essentials, TextEditor integration, all 4 popup/modal systems, z-order, secondary windows |
references/troubleshooting.md | Debugging crashes, startup timeouts, rendering issues | Memory management, destruction ordering, AU/VST3 startup optimization, dirty rects, watchdog timer, common mistakes table, file reference |