From c5051b6c9de0bc0689a145c70978ad3cc64c3403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:44:56 -0400 Subject: [PATCH 1/8] ui: gui_window concept + shared editor resolution seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avnd::gui_windowed_ui (T::ui::window with open/close/idle/size), avnd::gui_parent / gui_host (host parent handle + automation-gesture fn-ptr protocol: flat param index, normalized [0,1]), and avnd::make_ui_editor which resolves, in order: the author-provided Tier C window, then — only when a target is built with a soft-UI backend (AVND_SOFT_UI_EDITOR) — the reference editor for declarative layouts. Architecture plan in CUSTOM_UI_PLAN.md. Co-Authored-By: Claude Fable 5 --- CUSTOM_UI_PLAN.md | 502 +++++++++++++++++++++++++++ include/avnd/binding/ui/editor.hpp | 64 ++++ include/avnd/concepts/all.hpp | 1 + include/avnd/concepts/gui_window.hpp | 76 ++++ 4 files changed, 643 insertions(+) create mode 100644 CUSTOM_UI_PLAN.md create mode 100644 include/avnd/binding/ui/editor.hpp create mode 100644 include/avnd/concepts/gui_window.hpp diff --git a/CUSTOM_UI_PLAN.md b/CUSTOM_UI_PLAN.md new file mode 100644 index 00000000..7f65e5dc --- /dev/null +++ b/CUSTOM_UI_PLAN.md @@ -0,0 +1,502 @@ +# Custom UI support for Avendish plug-ins + +Plan for letting Avendish plug-in authors ship graphical user interfaces that +work in plug-in hosts (VST2, VST3, CLAP editors), standalones, WebAssembly and +even bare-metal targets (ESP32-class LCDs) — without depending on Qt or a +browser, and without changing the author-facing model that already exists. + +Status: proposal. Date: 2026-07-02. + +--- + +## 1. Goals and constraints + +1. **Author API stability.** Avendish already defines a complete, backend-neutral + UI authoring model (`struct ui` declarative layouts, `avnd::painter` custom + widgets, UI↔processor message bus). A plug-in written today against that + model must gain a working VST/CLAP editor *without source changes*. +2. **Framework neutrality.** Authors who want to bring their own UI stack + (Dear ImGui, JUCE, Qt, raw GDI…) must be able to, through a small set of + host-attachment concepts. The reference implementation must itself be built + on those concepts, proving they are sufficient. +3. **Extreme portability.** The reference stack must build with MSVC, clang on + msys2, gcc on FreeBSD, Emscripten, and xtensa/riscv gcc for ESP32. This + forces: no mandatory GPU context, no mandatory OS windowing, C/C++ only, + no runtime dependencies beyond libc (and even that optional on MCU). +4. **The VST editor contract.** Hosts hand the plug-in a *native parent window* + (HWND / NSView* / X11 Window) and a UI-thread idle/timer callback. The + plug-in renders a child into it and never owns the event loop. Every layer + below must fit this inversion of control. +5. **License compatibility with Avendish (AGPL).** Copyleft dependencies + (GPL/LGPL) are acceptable; permissive (ISC/MIT) is a bonus that keeps + every downstream licensing option open, not a requirement. + +## 2. What exists today (inventory) + +The author-facing model is done; the host-facing side is missing. + +| Piece | State | Where | +|---|---|---| +| Declarative layout DSL (hbox/vbox/grid/tabs/split/custom) | ✅ complete | `include/halp/layout.hpp`, concepts in `include/avnd/concepts/layout.hpp` | +| Canvas painter concept | ✅ complete | `include/avnd/concepts/painter.hpp` (paths, béziers, transforms, text, images) | +| Custom interactive widgets (`paint()` + `mouse_press/move/release`) | ✅ complete | e.g. `examples/Advanced/UI/Multislider.hpp` | +| Automation-gesture transactions | ✅ complete | `halp::transaction` in `include/halp/custom_widgets.hpp` | +| UI↔processor message bus (auto-serializing) | ✅ complete | `include/avnd/concepts/ui.hpp`, `examples/Helpers/UiBus.hpp` | +| Painter implementations | Qt (score), Canvas2D (`binding/wasm/painter.hpp`), QML | host-bound: need Qt or a browser | +| Nuklear prototype | ⚠️ dead end for plug-ins | `binding/ui/nuklear_layout_ui.hpp` owns a GLFW window + desktop GL 4.5; cannot embed into a host parent, cannot run on MCU | +| VST2 editor | ❌ | `EditOpen/EditClose/EditIdle/EditGetRect` opcodes declared in `vintage.hpp:91-97` but unhandled in `dispatch.hpp` | +| VST3 editor | ❌ | `controller_base.hpp:102` — `createView()` returns `nullptr` | +| CLAP GUI | ❌ | no `clap.gui` extension in `binding/clap/audio_effect.hpp` | +| Parameter mirror for UIs | ✅ (VST2) | `binding/vintage/atomic_controls.hpp` atomic float array + `parameter_input_introspection` | + +## 3. Library survey → picks + +Extensive survey (July 2026) of microui, Nuklear, Dear ImGui, raygui, GuiLite, +Jet, LVGL, Clay, Elements, NanoGUI/NanoVG, RmlUi, Slint, NAppGUI, U8g2, luigi, +TIGR, Fenster, olive.c, egui, plus plugin prior art (pugl, DPF, iPlug2, CPLUG, +VSTGUI, JUCE, the free-audio clap-imgui examples). Full notes in §11. + +Key structural finding: the plug-in UI field has converged on +**windowing shim + renderer-agnostic widget layer whose output you rasterize +yourself** (DPF, CPLUG, clap-imgui all do this). Libraries that *own their +window* (raygui/raylib, TIGR, NanoGUI, sokol) structurally fail the VST +contract; libraries that *emit commands or pixels* (Nuklear, microui, Clay, +ImGui, LVGL) pass it. + +Picks, per layer (each layer independently swappable): + +| Layer | Pick | Why | Alternatives kept open | +|---|---|---|---| +| Window embedding (desktop) | **pugl** (ISC, C99, zero deps) | `puglSetParent()` before realize = exactly the VST contract; `puglUpdate(world, 0)` from host idle; stub backend for pure software blitting; de-facto standard in LV2/DPF/CPLUG world | ~600-line in-tree shim if we ever want zero fetch | +| Standard widgets + input logic | **Nuklear** (MIT/Unlicense, single C89 header, ~18 kLoC) | Only surveyed library with: in-tree software rasterizer (`rawfb`), native `nk_knob`, fixed-buffer no-libc mode (MCU-proven), active maintenance (v4.13.3, 2026); text editing/combos/skinning for free | Dear ImGui (richest ecosystem, GPU-oriented, PSRAM-hungry on MCU); LVGL (if the embedded-product end dominates); microui (pedagogical minimum, dormant) | +| `avnd::painter` rasterizer | **canvas_ity** (ISC, single header ~2.3 kLoC) | HTML5-Canvas semantics map 1:1 onto `avnd::painter` (which was modeled on Qt/Canvas2D); pure software, no deps, TTF text, gradients, clipping | PlutoVG (C, MIT, lighter surfaces — better for tiny-RAM MCU); ThorVG | + +Licensing: Avendish is AGPL, so copyleft dependencies would be fine too — +the picks are ISC/MIT/Unlicense on technical merit, which additionally keeps +every downstream licensing option open. (Candidates eliminated during the +survey were dropped for technical reasons, not copyleft: Cairo on build +weight, AGG on abandonment, Slint on the Rust toolchain requirement.) + +Why not "just Nuklear" for custom widgets too: Nuklear's command list has no +arbitrary path fill, no bézier fill, no transforms — it cannot implement +`avnd::painter`. Why not "just canvas_ity" for everything: then we own text +editing, focus, IME, combo popups… That is exactly the code one should not +write twice. Hybrid: Nuklear draws/handles *standard* controls, canvas_ity +backs *custom* `paint()` widgets, both into the **same RGBA framebuffer**. + +## 4. Architecture overview + +Three layers, top-down. Only Layer 1 is visible to plug-in authors. + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ L1 Author API (unchanged + one new escape hatch) │ +│ A. struct ui { hbox/vbox/… } — declarative, zero code │ +│ B. custom_control + paint(avnd::painter auto) │ +│ C. NEW struct ui::window { open/idle/close } — any framework │ +├────────────────────────────────────────────────────────────────────┤ +│ L2 Reference UI runtime (avnd/binding/ui/soft/) │ +│ "pixels in, events in, pixels out" — no OS calls in the core │ +│ • layout walk of struct ui → Nuklear widgets (nk_knob, …) │ +│ • custom paint() widgets → soft_painter (canvas_ity) │ +│ • both composite into one RGBA8 framebuffer (nk rawfb) │ +│ • input: pointer/key events pushed in by the shell │ +│ • param edits → gesture hooks; bus msgs → SPSC queues │ +├────────────────────────────────────────────────────────────────────┤ +│ L3 Shells (own the window/surface, drive the runtime) │ +│ desktop plug-in: pugl child window, blit framebuffer │ +│ VST2 EditOpen/EditIdle/EditClose/EditGetRect │ +│ VST3 IPlugView (attached/removed/onSize, IRunLoop timer) │ +│ CLAP clap.gui + clap.timer-support │ +│ standalone: pugl top-level window │ +│ wasm: framebuffer → canvas putImageData (or existing │ +│ Canvas2D painter path, which stays) │ +│ ESP32/bare: no shell — user calls render()/pointer_event() │ +│ and flushes the framebuffer to the LCD │ +└────────────────────────────────────────────────────────────────────┘ +``` + +The reference runtime (L2) is deliberately implemented *as* a Tier C provider: +the plug-in bindings only ever see the Layer-1C concepts. That keeps the +binding glue framework-agnostic and proves the escape hatch is complete. + +## 5. Layer 1C — host-attachment concepts (new) + +New header `include/avnd/concepts/gui_window.hpp` (names bikesheddable): + +```cpp +namespace avnd +{ +enum class gui_api : uint8_t +{ win32_hwnd, cocoa_nsview, x11_window, wayland_surface, web_canvas, framebuffer }; + +// What the host/binding gives the UI when opening it. +struct gui_parent +{ + void* handle; // HWND / NSView* / (X11 Window)(uintptr_t) / css selector + gui_api api; + double scale; // host-reported HiDPI factor, 1.0 if unknown +}; + +// Callbacks the UI can invoke on the binding. All must be called on the UI thread. +struct gui_host +{ + // parameter automation gestures, param index = flat index in + // parameter_input_introspection order, value normalized to [0,1] + void (*begin_edit)(void* ctx, int param); + void (*perform_edit)(void* ctx, int param, double normalized); + void (*end_edit)(void* ctx, int param); + void (*request_resize)(void* ctx, int w, int h); // may be refused by host + void* ctx; +}; + +// The escape hatch: a plug-in (or the reference runtime on its behalf) +// exposes T::ui::window with this shape. +template +concept gui_windowed_ui = requires(W w, gui_parent p, gui_host h) +{ + w.open(p, h); // create child window/context inside p, show it + w.close(); // tear down (host may destroy parent right after) + w.idle(); // host UI-thread tick: pump events, repaint if dirty + { w.size() } -> std::same_as>; // current logical size +}; + +// optional refinements, detected with requires(): +// w.set_scale(double) — host DPI changed +// w.set_size(int, int) — host resized us (resizable editors) +// { W::resizable() } -> bool +// { w.supports(gui_api) } -> bool — default: platform default only +} +``` + +Design notes: + +- `idle()` is the only clock. Every host in scope provides one (VST2 + `effEditIdle` + a 30 Hz timer we install ourselves where hosts are lazy, + VST3 `IRunLoop`/platform timer, CLAP `timer-support`, `requestAnimationFrame` + on the web, the user's main loop on MCU). The runtime never spawns threads. +- Parameter *values* do not travel through `gui_host`; they go through each + binding's existing parameter mirror (see §8). `gui_host` only carries the + gesture protocol, which is the one thing the author model (`halp::transaction`) + already distinguishes and which every host API demands + (`audioMasterAutomate`, `IComponentHandler::begin/perform/endEdit`, + CLAP `GESTURE_BEGIN/PARAM_VALUE/GESTURE_END` events). +- The bus (`ui::bus::send_message` / `process_message`) transport is owned by + the binding (§8), not by the window concept — same split as today in score. + +Author-facing Tier C example (bring-your-own-framework): + +```cpp +struct MyPlugin { + ... + struct ui { + struct window { + static constexpr auto width = 640, height = 400; + void open(avnd::gui_parent p, avnd::gui_host h); // e.g. create a JUCE + void close(); // component, an ImGui + void idle(); // context, a Qt widget… + std::pair size() const; + }; + }; +}; +``` + +If `T::ui` has no `window` member but is a layout tree (today's model), the +binding instantiates the reference runtime's `soft::window` which satisfies +`gui_windowed_ui` — Tier A/B authors write nothing. + +## 6. Layer 2 — reference runtime `avnd/binding/ui/soft/` + +Proposed files: + +``` +include/avnd/binding/ui/soft/ + framebuffer.hpp // rgba8 span + width/height/stride, no ownership + painter.hpp // soft_painter: avnd::painter over canvas_ity + widgets.hpp // layout-DSL → Nuklear walk (ports nuklear_layout_ui.hpp) + runtime.hpp // soft::ui_runtime: composition + input + dirty tracking + window.hpp // soft::window: gui_windowed_ui via pugl (desktop) + surface.hpp // soft::surface: windowless variant (wasm/MCU) +``` + +### 6.1 The pure core: `soft::ui_runtime` + +No OS includes. Owns: + +- one `nk_context` in `nk_init_fixed` mode (fixed arena, MCU-friendly), +- one canvas_ity canvas sized to the root layout, +- the instantiated `typename avnd::ui_type::type` layout tree, +- pointers to the parameter mirror + bus queues supplied by the binding. + +API (called by shells): + +```cpp +struct pointer_event { double x, y; int buttons; double wheel; }; +struct key_event { uint32_t keycode; char32_t ch; bool down; }; + +template +struct ui_runtime +{ + void set_viewport(int w, int h, double scale); + void pointer(pointer_event); + void key(key_event); + bool tick(); // widget pass; true if repaint needed + void render(framebuffer out); // nk rawfb pass + custom-widget canvases +}; +``` + +`tick()` runs the Nuklear frame: walks the layout tree exactly like the +existing `nkl::layout_ui` (`binding/ui/nuklear_layout_ui.hpp:74-181` is 80 % +reusable), mapping widget hints of halp controls to Nuklear widgets: + +| halp widget hint | Nuklear | +|---|---| +| `knob` | `nk_knob_float/int` | +| `hslider`/`vslider` | `nk_slider_float/int` | +| `spinbox` | `nk_property_float/int` | +| `toggle`/`checkbox` | `nk_checkbox_label` | +| `combobox`/enums | `nk_combo` | +| `button`/`bang` | `nk_button_label` | +| `lineedit` | `nk_edit_string` | +| `xy` pad, others | custom-painted fallback (Tier B machinery) | + +For **Tier B custom widgets** (`custom_control` etc.), the walk +reserves the widget's rect via `nk_widget()`, routes mouse events to +`W::mouse_press/move/release` (mapped into widget-local coordinates), and at +render time runs `W::paint(soft_painter{...})` with the painter translated +onto the widget's rect in the shared framebuffer. `ctx.update()` marks the +widget dirty (matching the Qt/WASM painter contract). Static-sized widgets +cache their canvas; only dirty ones re-rasterize. + +`render()` replays the Nuklear command queue through the `rawfb` software +rasterizer into the caller's framebuffer, then composites the custom-widget +canvases. One font atlas (stb_truetype via Nuklear) shared with canvas_ity's +text where possible; scale changes rebake the atlas (the known Nuklear +HiDPI cost — acceptable, it happens on monitor changes only). + +`halp::transaction` on custom widgets and Nuklear's +`nk_widget_has_mouse_click_down` edges on standard widgets both funnel into +`gui_host::begin/perform/end_edit`. + +### 6.2 Desktop shell: `soft::window` (pugl) + +- `open()`: `puglSetParent(view, (PuglNativeView)p.handle)` + + `puglSetBackend(view, puglStubBackend())`, realize, show. +- `idle()`: `puglUpdate(world, 0)`; on expose, `runtime.render()` into a + per-view pixel buffer and blit — Win32 `StretchDIBits`, X11 + `XPutImage`/XShm, macOS drawing the buffer into the NSView's layer + (pugl stub backend exposes the native surface for all three). +- Wayland: not a plug-in concern in practice (hosts hand out X11 windows); + standalone Wayland goes through pugl's normal path when it lands there. +- No global state: pugl worlds are per-instance, so many plug-in instances in + one process coexist (mandatory for plug-ins; rules out anything with + singletons). + +### 6.3 Windowless shell: `soft::surface` (WASM, ESP32, tests) + +```cpp +soft::surface ui{eff}; +ui.set_viewport(320, 240, 1.0); +// main loop / rAF / lvgl-style tick: +ui.pointer({touch_x, touch_y, touch_down}); +if (ui.tick()) +{ + ui.render({psram_buf, 320, 240}); + esp_lcd_panel_draw_bitmap(panel, 0, 0, 320, 240, psram_buf); +} +``` + +On WASM this same path blits via `putImageData`; the existing Canvas2D painter +backend (`binding/wasm/painter.hpp` + `ui_bridge.hpp`) remains as the +DOM-native alternative. On MCU, memory is the constraint: Nuklear fixed arena +(~64–256 KB), RGBA8 framebuffer (320×240 = 300 KB → PSRAM, or render in +horizontal bands like LVGL to stay in SRAM — canvas_ity can't band easily, so +the MCU tier may prefer the PlutoVG painter variant; the painter is behind the +concept, so this is a swap, not a redesign). Golden-test hook for free: the +differential harness can `render()` to a buffer and image-diff UIs headlessly. + +## 7. Layer 3 — plug-in binding glue + +### 7.1 VST2 (`binding/vintage`) + +`dispatch.hpp` gains (only when `avnd::gui_windowed_ui<...>` or a layout `ui` +exists — SFINAE'd so UI-less plug-ins compile exactly as today): + +```cpp +case EffectOpcodes::EditGetRect: { + static vintage::Rect r{0, 0, (int16_t)h, (int16_t)w}; + *(vintage::Rect**)ptr = &r; return 1; } +case EffectOpcodes::EditOpen: + self.editor.open({.handle = ptr, .api = native_api(), .scale = 1.0}, + self.editor_host()); return 1; +case EffectOpcodes::EditClose: self.editor.close(); return 1; +case EffectOpcodes::EditIdle: self.editor.idle(); return 1; +``` + +plus `EffectFlags::HasEditor` in `processor_setup.hpp`, and +`gui_host::perform_edit` → `setParameterAutomated`/`audioMasterAutomate` +(gestures via `audioMasterBeginEdit/EndEdit`). Parameter values reuse +`atomic_controls.hpp` verbatim — the editor reads/writes the same atomics the +host automation writes. + +### 7.2 VST3 (`binding/vst3`) + +New `binding/vst3/view.hpp`: `template struct plug_view final : +Steinberg::IPlugView` — `isPlatformTypeSupported` (kPlatformTypeHWND / +NSView / X11EmbedWindowID), `attached()` → `editor.open(...)`, `removed()` → +`close()`, `onSize`, `canResize`. Ticks: Win32 `SetTimer`, macOS +`CFRunLoopTimer`, Linux the host's `IRunLoop` (query from the frame — this is +the well-known VST3-Linux dance and the reason `idle()` is our only clock). +`controller_base.hpp:102` `createView("editor")` returns it. Gestures map to +`IComponentHandler::beginEdit/performEdit/endEdit` with VST3-normalized +values — the machinery for param normalization already exists in the +controller. + +### 7.3 CLAP (`binding/clap`) + +Implement `clap_plugin_gui` (`is_api_supported`, `get_preferred_api`, +`create`, `destroy`, `set_scale` → `set_scale()`, `get_size`, `can_resize`, +`set_size`, `set_parent` → `open()`, `show`, `hide`) plus +`clap_plugin_timer_support`/`on_timer` → `idle()` (fallback: hosts that lack +timer support get a pugl-side timer). Gestures become +`CLAP_EVENT_PARAM_GESTURE_BEGIN/PARAM_VALUE/GESTURE_END` pushed on the +out-event queue via the existing param flush path. CLAP is the cleanest of the +three and should be the first target. + +### 7.4 Standalone / examples + +`avnd_make_standalone_ui(...)`: pugl top-level window + `soft::window`, +replacing the GLFW/GL4.5 demo path, so every example with a `ui` gets a +runnable desktop preview on Windows/macOS/X11/FreeBSD with zero GPU +requirements. + +## 8. Cross-cutting: parameters, bus, threading + +- **Threading model:** UI code runs only on the host UI thread (inside + `open/idle/close`). Audio runs on the audio thread. They meet only at: + 1. the **parameter mirror** — per-binding, already exists or is trivial: + vintage's `std::atomic` array; CLAP/VST3 event queues with a + UI-side shadow value refreshed in `idle()`; + 2. the **message bus** — two SPSC queues (moodycamel `concurrentqueue` is + already a dependency) carrying the serialized messages; + UI→proc drained at the top of `process()`, proc→UI drained in `idle()` + which then calls `ui::bus::process_message(ui, msg)`. This reuses the + exact serialization machinery score's bus uses today; only the transport + is new (and shared by all three plug-in bindings). +- **Value display / control feedback:** `idle()` diffs the mirror against the + last-shown values and marks affected widgets dirty; no repaint when nothing + changed (important for laptop battery + MCU). +- **HiDPI:** logical-pixel layout; the framebuffer is `ceil(w*scale) × + ceil(h*scale)`; Nuklear style + font atlas rebaked on scale change; + canvas_ity just scales its transform. VST2 has no scale protocol (assume + 1.0 or Win32 per-monitor query); VST3 `IPlugViewContentScaleSupport`; CLAP + `set_scale`. +- **Resizing:** phase 1 fixed-size editors (`width/height` metas already in + the DSL); phase 2 `request_resize`/`set_size` for hosts that allow it. + +## 9. CMake & packaging + +- `cmake/avendish.ui.soft.cmake`: FetchContent **pugl**, **Nuklear**, + **canvas_ity** (three tiny fetches, no transitive deps), guarded like the + Qt UI file so nothing is fetched when the backend is off. +- Backend selection: `avnd_make_vintage/vst3/clap(... UI soft)` or a global + `AVND_UI_BACKEND=soft|qt|none` default. The glue in each binding is + compiled only when the target type actually has a `ui` (concept-gated), so + the zero-UI cost stays zero. +- ESP32: the runtime + canvas headers are plain C/C++ — usable directly from + an ESP-IDF component; no cmake work beyond an example. + +## 10. End-to-end example (author side, unchanged code) + +`examples/Advanced/UI/Multislider.hpp` — today score-only — becomes a VST2/ +VST3/CLAP plug-in with a working editor: the `struct ui` tree renders the +spinbox via `nk_property_int`, the `MultisliderWidget` custom control renders +via `soft_painter`, drags emit `begin/perform/end_edit` through its existing +`halp::transaction`, and the cursor bus messages flow over the SPSC queues. +Zero source changes to the example — that is the acceptance test. + +New Tier C example `examples/Raw/NuklearDirectUi.hpp` for authors who want to +skip the DSL and write immediate-mode code themselves: + +```cpp +struct ui { + struct window { + static constexpr auto width = 400, height = 250; + // convenience middle tier: runtime supplies context + host, author draws + void render(nk_context* nk) { + if (nk_begin(nk, "main", nk_rect(0,0,400,250), 0)) { + nk_layout_row_dynamic(nk, 120, 1); + nk_knob_float(nk, 0.f, &gain, 1.f, 0.01f, NK_DOWN, 60.f); + } + nk_end(nk); + } + float gain{0.5f}; + }; +}; +``` + +(`soft::window` detects `render(nk_context*)` and skips the layout walk.) + +## 11. Implementation phases + +1. **Core runtime** — `soft::ui_runtime`: port the layout walk off + GLFW/GL onto `rawfb` + fixed memory; `soft_painter` over canvas_ity; + headless golden-image test in the existing golden harness. +2. **CLAP editor** (cleanest host API, easiest to test with `clap-host`/ + Bitwig): `clap.gui` + timer + gestures. Windows first, then X11, macOS. +3. **VST2 + VST3 glue** (small once §7.1/7.2 exist), standalone preview tool. +4. **WASM + ESP32 surfaces**; PlutoVG painter variant if canvas_ity RAM is + prohibitive on MCU; banded rendering investigation. +5. **Polish:** HiDPI rebake, resizable editors, theming (map `halp::colors` + palette onto `nk_style`), docs chapter under `book/src/advanced/ui.*`. + +## 12. Risks / open questions + +- **Nuklear HiDPI is manual** — style scaling + atlas rebake is on us + (bounded, well-trodden problem). +- **canvas_ity memory on MCU** — internal buffers are generous; mitigation: + PlutoVG painter variant behind the same concept (§6.3). +- **Look & feel** — stock Nuklear looks like a dev tool; a `halp::colors`- + driven `nk_style` theme + the knob/slider skinning API needs a design pass + to look like an instrument, not a debugger. +- **Text input/IME** on exotic hosts — Nuklear's edit widget covers ASCII + well; full IME is explicitly out of scope for v1 (same stance as DPF). +- **First-mover**: nobody ships Nuklear inside a VST today (ImGui yes, many + times). The rawfb/GDI demos cover ~90 % of the novel ground; fallback if it + stalls: the L1C/L2/L3 split is unchanged if the widget layer is swapped for + Dear ImGui + software triangle rasterizer or LVGL. + +## Appendix A — survey shortlist rationale + +- **Nuklear** — single C89 header, MIT/Unlicense, in-tree software rasterizer + (`demo/rawfb`), native `nk_knob`, `nk_init_fixed` no-malloc mode, active + (v4.13.3 May 2026). Chosen: widget layer. +- **pugl** — ISC, C99, zero deps, made precisely for plug-in editor embedding + (`puglSetParent`), stub backend for software blit, per-instance worlds. + Chosen: desktop embedding. +- **canvas_ity** — ISC, one header, HTML5-canvas semantics ≈ `avnd::painter` + verbatim, software. Chosen: painter. Known ceiling: internal bitmap is + float RGBA (16 B/px) with no banded mode — desktop-fine, MCU-hostile. +- **ctx** (ctx.graphics) — ISC (verified 2026-07: not LGPL as often + assumed), C, actively maintained, MCU-first software rasterizer: 1–32 bpp + incl. RGB565, tiled partial redraw, SIMD, ESP32 demos. Leading candidate + for the MCU-tier painter behind the same concept; costs: very large + amalgamated header, idiosyncratic API, some font gaps. PlutoVG (MIT, C99, + 4 B/px surfaces, stb_truetype) is the conservative fallback. +- **Dear ImGui** — best ecosystem & plugin precedent, but GPU-oriented (the + maintained CPU path is SDL_Renderer; `imgui_sw` is dormant/unlicensed) and + PSRAM-hungry on MCU. Runner-up; a later `imgui` Tier C adapter is cheap. +- **LVGL** — the embedded king (official ESP-IDF component, Emscripten port, + MIT, extremely active), full toolkit; but big tree, retained-mode idiom of + its own, no host-window adoption out of the box. Runner-up if priorities + flip toward shipping MCU products. +- **microui** — 1.1 kLoC, beautiful teaching core, but dormant since 2024 and + only rect/text/icon/clip primitives. Not enough alone. +- **raygui/TIGR/NanoGUI/sokol/Fenster/luigi/NAppGUI** — own their window/event + loop: structurally incompatible with host-parented editors. +- **Elements** — audio-first and lovely, but Cairo + GTK3 on Linux and "not + production ready" per its own README. +- **Slint** — royalty-free tier excludes embedded distribution; Rust toolchain + in every cross-build. +- **GuiLite/Jet/U8g2/olive.c** — not widget toolkits in the needed sense + (framebuffer sprites / AGPL 3D rasterizer / mono LCD / bare rasterizer). diff --git a/include/avnd/binding/ui/editor.hpp b/include/avnd/binding/ui/editor.hpp new file mode 100644 index 00000000..580110e8 --- /dev/null +++ b/include/avnd/binding/ui/editor.hpp @@ -0,0 +1,64 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * Editor resolution shared by the plug-in bindings (CLAP, VST2, VST3, ...): + * + * - T::ui::window satisfying avnd::gui_windowed_ui wins: the author brings + * any UI framework; + * - otherwise, when the target is built with the soft UI backend + * (AVND_SOFT_UI_EDITOR, set by avnd_target_soft_ui), a declarative + * `struct ui` gets the reference pugl + Nuklear + canvas_ity editor; + * - otherwise there is no editor and the binding does not expose one. + */ + +#include +#include +#include + +#if defined(AVND_SOFT_UI_EDITOR) +#include +#endif + +#include +#include + +namespace avnd +{ +template +concept has_layout_ui = requires { sizeof(typename avnd::ui_type::type); }; + +namespace detail +{ +template +static constexpr auto ui_editor_type() +{ + if constexpr(avnd::has_gui_window) + return std::type_identity{}; +#if defined(AVND_SOFT_UI_EDITOR) + else if constexpr(has_layout_ui) + return std::type_identity>{}; +#endif + else + return std::type_identity{}; +} +} + +template +using ui_editor_t = typename decltype(detail::ui_editor_type())::type; + +template +static constexpr bool has_ui_editor = !std::is_void_v>; + +template + requires has_ui_editor +auto make_ui_editor(avnd::effect_container& fx) +{ + using editor_t = ui_editor_t; + if constexpr(std::is_constructible_v&>) + return std::make_unique(fx); + else + return std::make_unique(); +} +} diff --git a/include/avnd/concepts/all.hpp b/include/avnd/concepts/all.hpp index 6a9c4100..5a58dcf2 100644 --- a/include/avnd/concepts/all.hpp +++ b/include/avnd/concepts/all.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include diff --git a/include/avnd/concepts/gui_window.hpp b/include/avnd/concepts/gui_window.hpp new file mode 100644 index 00000000..863833ab --- /dev/null +++ b/include/avnd/concepts/gui_window.hpp @@ -0,0 +1,76 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later OR BSL-1.0 OR CC0-1.0 OR CC-PDCC OR 0BSD */ + +#include +#include + +namespace avnd +{ +// Host-attachment layer for plug-in editors (VST2 effEditOpen, VST3 IPlugView, +// CLAP clap.gui, standalone windows, ...). The binding owns the host protocol; +// the UI only ever sees these three types. See CUSTOM_UI_PLAN.md. + +enum class gui_api : uint8_t +{ + win32_hwnd, + cocoa_nsview, + x11_window, + wayland_surface, + web_canvas, + framebuffer +}; + +// What the host / binding hands the UI when opening it. +struct gui_parent +{ + // HWND / NSView* / (uintptr_t)X11 Window / CSS selector; null for + // gui_api::framebuffer where the shell only ever asks for pixels. + void* handle{}; + gui_api api{}; + // Host-reported HiDPI factor. 1.0 when the host has no scale protocol. + double scale{1.}; +}; + +// Callbacks the UI may invoke on the binding. UI thread only. +// Parameter indices are flat indices in avnd::parameter_input_introspection +// order; values are normalized to [0, 1]. Only the automation *gesture* +// protocol lives here -- parameter values travel through each binding's +// parameter mirror, bus messages through its message-bus transport. +struct gui_host +{ + void (*begin_edit)(void* ctx, int param) = [](void*, int) {}; + void (*perform_edit)(void* ctx, int param, double normalized) + = [](void*, int, double) {}; + void (*end_edit)(void* ctx, int param) = [](void*, int) {}; + void (*request_resize)(void* ctx, int w, int h) = [](void*, int, int) {}; + void* ctx{}; +}; + +// The escape hatch: T::ui::window with this shape can be attached by any +// plug-in binding, whatever framework it is implemented with. The reference +// soft:: runtime is itself exposed through this concept. +template +concept gui_windowed_ui = requires(W w, gui_parent p, gui_host h) { + w.open(p, h); + w.close(); + w.idle(); + { w.size() } -> std::convertible_to>; +}; + +// Optional refinements, detected individually by bindings: +// w.set_scale(2.0); -- host DPI changed +// w.set_size(800, 600); -- host resized the editor +// { W::resizable() } -> bool; +// { w.supports(gui_api{}) } -> bool; +template +concept gui_resizable_ui = gui_windowed_ui && requires(W w) { + w.set_size(0, 0); + { W::resizable() } -> std::convertible_to; +}; + +// Detect T::ui::window on a processor type. +template +concept has_gui_window = requires { sizeof(typename T::ui::window); } + && gui_windowed_ui; +} From 87aabf34f6427776027ef530a1db931793a79020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:45:08 -0400 Subject: [PATCH 2/8] ui: lock-free message-bus transport + pfr-based payload serialization ui::bus / Tier C send_message/process_message endpoints over moodycamel queues: UI thread enqueues (may allocate), audio thread try_enqueues on preallocated storage and never blocks. avnd::bus_serial handles non-trivially-copyable payloads (string/vector/aggregate); payloads a serializer can't handle silently disable that bus direction, by design. Co-Authored-By: Claude Fable 5 --- include/avnd/binding/ui/message_transport.hpp | 179 ++++++++++++++++++ include/avnd/binding/ui/serialization.hpp | 168 ++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 include/avnd/binding/ui/message_transport.hpp create mode 100644 include/avnd/binding/ui/serialization.hpp diff --git a/include/avnd/binding/ui/message_transport.hpp b/include/avnd/binding/ui/message_transport.hpp new file mode 100644 index 00000000..ab33a4db --- /dev/null +++ b/include/avnd/binding/ui/message_transport.hpp @@ -0,0 +1,179 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * Lock-free message-bus transport shared by the plug-in bindings. + * + * The UI thread enqueues towards the processor with enqueue() (may allocate, + * not RT); the audio thread enqueues towards the UI with try_enqueue() on a + * preallocated queue (never allocates queue storage; overflow drops) and + * drains with try_dequeue() (lock-free). Each binding decides where the + * drains live (process() on the audio side, editor timer on the UI side). + */ + +#include +#include +#include + +#include + +#include +#include + +namespace avnd +{ +// Extract the message type carried by a std::function member +// (T::send_message / ui::bus::send_message). +template +struct function_arg; +template +struct function_arg> +{ + using type = std::remove_cvref_t; +}; + +namespace detail +{ +template +static constexpr auto ui_to_proc_msg() +{ + if constexpr(avnd::has_gui_to_processor_bus) + return std::type_identity().send_message)>>::type>{}; + else + return std::type_identity{}; +} + +template +static constexpr auto proc_to_ui_msg() +{ + if constexpr(avnd::has_processor_to_gui_bus) + return std::type_identity().send_message)>>::type>{}; + else + return std::type_identity{}; +} +} + +template +using ui_to_proc_msg_t = typename decltype(detail::ui_to_proc_msg())::type; +template +using proc_to_ui_msg_t = typename decltype(detail::proc_to_ui_msg())::type; + +// ---- Tier C: the author window is the UI-side bus endpoint ---- +// window.send_message (std::function member, wired by the binding) sends to +// the processor; window.process_message(msg) receives from it. +namespace detail +{ +template +static constexpr auto window_ui_to_proc_msg() +{ + if constexpr(avnd::has_gui_window) + { + if constexpr(requires(typename T::ui::window w) { + typename function_arg>::type; + }) + return std::type_identity().send_message)>>::type>{}; + else + return std::type_identity{}; + } + else + return std::type_identity{}; +} + +// Argument of T::send_message regardless of a declarative ui::bus +template +static constexpr auto processor_msg() +{ + if constexpr(requires { + typename function_arg().send_message)>>::type; + }) + return std::type_identity().send_message)>>::type>{}; + else + return std::type_identity{}; +} +} + +template +using window_ui_to_proc_msg_t + = typename decltype(detail::window_ui_to_proc_msg())::type; +template +using processor_msg_t = typename decltype(detail::processor_msg())::type; + +template +static constexpr bool window_sends_to_processor + = !std::is_void_v>; + +template +static constexpr bool window_receives_from_processor = [] { + if constexpr(avnd::has_gui_window && !std::is_void_v>) + return requires(typename T::ui::window w, processor_msg_t m) { + w.process_message(std::move(m)); + }; + else + return false; +}(); + +// ---- Unified predicates & message types across both tiers ---- +template +static constexpr bool ui_sends_to_processor + = has_gui_to_processor_bus || window_sends_to_processor; +template +static constexpr bool processor_sends_to_ui + = has_processor_to_gui_bus || window_receives_from_processor; + +namespace detail +{ +template +static constexpr auto any_ui_to_proc_msg() +{ + if constexpr(has_gui_to_processor_bus) + return std::type_identity>{}; + else + return std::type_identity>{}; +} +} +template +using any_ui_to_proc_msg_t = typename decltype(detail::any_ui_to_proc_msg())::type; +template +using any_proc_to_ui_msg_t = processor_msg_t; + +struct no_queue +{ +}; + +template +struct ui_to_proc_queue +{ + moodycamel::ConcurrentQueue queue{128}; +}; + +template +struct proc_to_ui_queue +{ + moodycamel::ConcurrentQueue queue{1024}; +}; + +// The queue pair a binding embeds when T has a bus (declarative ui::bus or +// a Tier C window endpoint); empty otherwise. +template +struct bus_transport +{ + AVND_NO_UNIQUE_ADDRESS + std::conditional_t< + ui_sends_to_processor, ui_to_proc_queue>, + no_queue> + to_processor; + + AVND_NO_UNIQUE_ADDRESS + std::conditional_t< + processor_sends_to_ui, proc_to_ui_queue>, + no_queue> + to_ui; +}; +} diff --git a/include/avnd/binding/ui/serialization.hpp b/include/avnd/binding/ui/serialization.hpp new file mode 100644 index 00000000..9b642ed5 --- /dev/null +++ b/include/avnd/binding/ui/serialization.hpp @@ -0,0 +1,168 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * Minimal binary serialization for UI <-> processor bus payloads that must + * cross a host boundary (VST3 IMessage). Not a general-purpose format: + * both ends are the same binary, so native layout/endianness is fine. + * + * Supported message shapes, recursively: + * - trivially-copyable types: raw bytes + * - std::string / std::vector: u32 length + bytes + * - aggregates of the above (via boost.pfr) + * Anything else makes `serializable` false and the caller keeps its + * previous behaviour (bus disabled for that direction). + */ + +#include + +#include +#include +#include +#include +#include + +namespace avnd::bus_serial +{ +template +concept trivial_payload = std::is_trivially_copyable_v; + +template +concept sized_payload = requires(T t) { + { t.size() } -> std::convertible_to; + t.resize(std::size_t{}); + t.data(); +} && trivial_payload().data())>>; + +template +constexpr bool serializable_impl(); + +template +constexpr bool aggregate_serializable(std::index_sequence) +{ + return (serializable_impl>() && ...); +} + +template +constexpr bool serializable_impl() +{ + using type = std::remove_cvref_t; + if constexpr(trivial_payload) + return true; + else if constexpr(sized_payload) + return true; + else if constexpr(std::is_aggregate_v) + return aggregate_serializable( + std::make_index_sequence>{}); + else + return false; +} + +template +concept serializable = !std::is_void_v && serializable_impl(); + +// ---- size ---- +template +inline std::size_t byte_size(const T& v) +{ + using type = std::remove_cvref_t; + if constexpr(trivial_payload) + { + return sizeof(type); + } + else if constexpr(sized_payload) + { + using elt = std::remove_cvref_t; + return sizeof(uint32_t) + v.size() * sizeof(elt); + } + else + { + std::size_t n = 0; + boost::pfr::for_each_field(v, [&](const auto& f) { n += byte_size(f); }); + return n; + } +} + +// ---- write ---- +template +inline void write(std::vector& out, const T& v) +{ + using type = std::remove_cvref_t; + if constexpr(trivial_payload) + { + const auto pos = out.size(); + out.resize(pos + sizeof(type)); + std::memcpy(out.data() + pos, &v, sizeof(type)); + } + else if constexpr(sized_payload) + { + using elt = std::remove_cvref_t; + const uint32_t count = (uint32_t)v.size(); + const auto pos = out.size(); + out.resize(pos + sizeof(uint32_t) + count * sizeof(elt)); + std::memcpy(out.data() + pos, &count, sizeof(uint32_t)); + if(count > 0) + std::memcpy(out.data() + pos + sizeof(uint32_t), v.data(), count * sizeof(elt)); + } + else + { + boost::pfr::for_each_field(v, [&](const auto& f) { write(out, f); }); + } +} + +template +inline std::vector to_bytes(const T& v) +{ + std::vector out; + out.reserve(byte_size(v)); + write(out, v); + return out; +} + +// ---- read ---- +// Returns false on truncated / malformed input (bounds-checked: bus +// payloads arrive from the host and must not be trusted blindly). +template +inline bool read(const char* data, std::size_t size, std::size_t& pos, T& v) +{ + using type = std::remove_cvref_t; + if constexpr(trivial_payload) + { + if(pos + sizeof(type) > size) + return false; + std::memcpy(&v, data + pos, sizeof(type)); + pos += sizeof(type); + return true; + } + else if constexpr(sized_payload) + { + using elt = std::remove_cvref_t; + uint32_t count{}; + if(pos + sizeof(uint32_t) > size) + return false; + std::memcpy(&count, data + pos, sizeof(uint32_t)); + pos += sizeof(uint32_t); + if(count > (size - pos) / sizeof(elt)) + return false; + v.resize(count); + if(count > 0) + std::memcpy(v.data(), data + pos, count * sizeof(elt)); + pos += count * sizeof(elt); + return true; + } + else + { + bool ok = true; + boost::pfr::for_each_field(v, [&](auto& f) { ok = ok && read(data, size, pos, f); }); + return ok; + } +} + +template +inline bool from_bytes(const void* data, std::size_t size, T& v) +{ + std::size_t pos = 0; + return read((const char*)data, size, pos, v) && pos == size; +} +} From 27b4805812bb072913931895febc9d1509b316b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:45:56 -0400 Subject: [PATCH 3/8] =?UTF-8?q?clap:=20editor=20support=20=E2=80=94=20clap?= =?UTF-8?q?.gui=20+=20clap.timer-support=20+=20gesture/bus=20glue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editors resolve through avnd::make_ui_editor (author-provided Tier C window, or the reference editor when a soft-UI backend is enabled at build time). Automation gestures flow editor -> lock-free gesture queue -> host params request_flush -> CLAP_EVENT_PARAM_GESTURE_BEGIN / PARAM_VALUE / GESTURE_END in params.flush. The message bus rides the same lock-free transport, drained in process() on the audio side and on the editor timer on the UI side. Co-Authored-By: Claude Fable 5 --- cmake/avendish.clap.cmake | 4 +- include/avnd/binding/clap/audio_effect.hpp | 349 ++++++++++++++++++++- include/avnd/binding/clap/gui.hpp | 105 +++++++ 3 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 include/avnd/binding/clap/gui.hpp diff --git a/cmake/avendish.clap.cmake b/cmake/avendish.clap.cmake index be63f90d..aa4c6088 100644 --- a/cmake/avendish.clap.cmake +++ b/cmake/avendish.clap.cmake @@ -45,6 +45,7 @@ target_include_directories( target_link_libraries(Avendish_clap_pch PUBLIC + concurrentqueue DisableExceptions ) @@ -130,6 +131,7 @@ function(avnd_make_clap) ${AVND_FX_TARGET} PUBLIC Avendish::Avendish_clap + concurrentqueue DisableExceptions ) @@ -137,7 +139,7 @@ function(avnd_make_clap) endfunction() add_library(Avendish_clap INTERFACE) -target_link_libraries(Avendish_clap INTERFACE Avendish) +target_link_libraries(Avendish_clap INTERFACE Avendish concurrentqueue) add_library(Avendish::Avendish_clap ALIAS Avendish_clap) target_sources(Avendish PRIVATE diff --git a/include/avnd/binding/clap/audio_effect.hpp b/include/avnd/binding/clap/audio_effect.hpp index 506cac3e..c5999758 100644 --- a/include/avnd/binding/clap/audio_effect.hpp +++ b/include/avnd/binding/clap/audio_effect.hpp @@ -3,6 +3,7 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ #include +#include #include #include #include @@ -126,6 +127,11 @@ struct SimpleAudioEffect : clap_plugin const clap_host& host; + using editor_t = clap_editor_t; + static constexpr bool has_editor = clap_has_editor; + AVND_NO_UNIQUE_ADDRESS + std::conditional_t, no_gui_state> gui; + AVND_NO_UNIQUE_ADDRESS avnd_clap::audio_bus_info audio_busses; AVND_NO_UNIQUE_ADDRESS avnd::process_adapter processor; @@ -185,6 +191,13 @@ struct SimpleAudioEffect : clap_plugin return &p.audio_ports; if(id_sv == "clap.note-ports") return &p.note_ports; + if constexpr(has_editor) + { + if(id_sv == CLAP_EXT_GUI) + return get_gui(); + if(id_sv == CLAP_EXT_TIMER_SUPPORT) + return get_timer_support(); + } return nullptr; }; @@ -197,11 +210,30 @@ struct SimpleAudioEffect : clap_plugin avnd::init_controls(effect); } + wire_bus_queues(); // Safe no-op handlers for worker.request / request_channels members: // an empty std::function call terminates under -fno-exceptions. avnd::wire_fallback_callbacks(effect); } + // processor → UI bus: enqueue from the audio thread into a preallocated + // lock-free queue (try_enqueue never allocates; overflow drops), drained + // on the editor timer. Installed at construction so send_message is + // always callable, and re-installed after editor creation since the soft + // runtime's constructor wires a synchronous default. + void wire_bus_queues() + { + if constexpr(has_editor && avnd::processor_sends_to_ui) + { + if constexpr(requires { this->effect.effect.send_message = nullptr; }) + { + this->effect.effect.send_message = [this](auto&& msg) { + gui.bus.to_ui.queue.try_enqueue(std::forward(msg)); + }; + } + } + } + void start() { // Allocate buffers, setup everything @@ -415,6 +447,20 @@ struct SimpleAudioEffect : clap_plugin void process_in_events(const clap_process& p) { + // Drain UI → processor bus messages queued by the editor. + if constexpr(has_editor && avnd::ui_sends_to_processor) + { + if constexpr(requires { + this->effect.effect.process_message( + std::declval>()); + }) + { + avnd::any_ui_to_proc_msg_t msg; + while(gui.bus.to_processor.queue.try_dequeue(msg)) + this->effect.effect.process_message(std::move(msg)); + } + } + // Parameter and transport events apply to every plug-in; only the MIDI // cases depend on having MIDI inputs. auto N = p.in_events->size(p.in_events); @@ -482,6 +528,7 @@ struct SimpleAudioEffect : clap_plugin void process_out_events(const clap_process& p) { + flush_gestures(p.out_events); // TODO module } @@ -615,7 +662,291 @@ struct SimpleAudioEffect : clap_plugin .description = avnd::get_description().data(), .features = features.data()}; - // wrapped in a function so the class is complete for the self()-> lambdas (MSVC C2027) + // ================= GUI (clap.gui + clap.timer-support) ================= + // See binding/clap/gui.hpp for editor resolution and the gesture queue. + + bool gui_create(const char* api, bool is_floating) + { + if constexpr(has_editor) + { + if(is_floating || std::string_view{api} != clap_native_window_api) + return false; + gui.editor = avnd::make_ui_editor(effect); + + // Wire the bus transport. The soft runtime wires it synchronously in + // its constructor, so replace both directions with the lock-free + // queues; Tier C windows get their send_message member connected. + if constexpr(requires { gui.editor->runtime(); }) + { + if constexpr(avnd::has_gui_to_processor_bus) + { + gui.editor->runtime().set_bus_to_processor([this](auto&& msg) { + gui.bus.to_processor.queue.enqueue(std::forward(msg)); + }); + } + } + else if constexpr(avnd::window_sends_to_processor) + { + if constexpr(requires { gui.editor->send_message; }) + { + gui.editor->send_message = [this](auto&& msg) { + gui.bus.to_processor.queue.enqueue(std::forward(msg)); + }; + } + } + wire_bus_queues(); + + if(auto t = (const clap_host_timer_support*)host.get_extension( + &host, CLAP_EXT_TIMER_SUPPORT)) + t->register_timer(&host, 16, &gui.timer_id); + return true; + } + return false; + } + + void gui_destroy() + { + if constexpr(has_editor) + { + if(gui.timer_id != CLAP_INVALID_ID) + { + if(auto t = (const clap_host_timer_support*)host.get_extension( + &host, CLAP_EXT_TIMER_SUPPORT)) + t->unregister_timer(&host, gui.timer_id); + gui.timer_id = CLAP_INVALID_ID; + } + if(gui.editor) + gui.editor->close(); + gui.editor.reset(); + } + } + + bool gui_set_parent(const clap_window* w) + { + if constexpr(has_editor) + { + if(!gui.editor || !w) + return false; + void* handle{}; +#if defined(_WIN32) + handle = (void*)w->win32; +#elif defined(__APPLE__) + handle = (void*)w->cocoa; +#else + handle = (void*)(uintptr_t)w->x11; +#endif + gui.editor->open( + avnd::gui_parent{handle, native_gui_api, gui.scale}, make_gui_host()); + return true; + } + return false; + } + + bool gui_get_size(uint32_t* width, uint32_t* height) + { + if constexpr(has_editor) + { + if(!gui.editor) + return false; + const auto [w, h] = gui.editor->size(); + *width = w; + *height = h; + return true; + } + return false; + } + + void gui_on_timer(clap_id timer_id) + { + if constexpr(has_editor) + { + if(gui.editor && timer_id == gui.timer_id) + { + if constexpr(avnd::processor_sends_to_ui) + { + avnd::any_proc_to_ui_msg_t msg; + while(gui.bus.to_ui.queue.try_dequeue(msg)) + { + if constexpr(requires { gui.editor->runtime(); }) + gui.editor->runtime().deliver_to_ui(std::move(msg)); + else if constexpr(requires { gui.editor->process_message(msg); }) + gui.editor->process_message(std::move(msg)); + } + } + gui.editor->idle(); + } + } + } + + avnd::gui_host make_gui_host() + { + avnd::gui_host h; + h.ctx = this; + if constexpr(has_editor) + { + h.begin_edit = [](void* ctx, int param) { + ((SimpleAudioEffect*)ctx)->push_gesture(gesture_event::begin, param); + }; + h.perform_edit = [](void* ctx, int param, double) { + ((SimpleAudioEffect*)ctx)->push_gesture(gesture_event::value, param); + }; + h.end_edit = [](void* ctx, int param) { + ((SimpleAudioEffect*)ctx)->push_gesture(gesture_event::end, param); + }; + h.request_resize = [](void* ctx, int w, int h_) { + auto& self = *(SimpleAudioEffect*)ctx; + if(auto g + = (const clap_host_gui*)self.host.get_extension(&self.host, CLAP_EXT_GUI)) + g->request_resize(&self.host, w, h_); + }; + } + return h; + } + + void push_gesture(gesture_event::kind kind, int param) + { + if constexpr(has_editor) + { + if(param < 0 || param >= param_in_info::size) + return; + gesture_event ev{ + .type = kind, .param_id = (clap_id)param_in_info::index_map[param]}; + // The runtime writes the control before firing the hook, so the + // current mapped value is the one to broadcast. + if(kind == gesture_event::value) + get_param_value(ev.param_id, &ev.value_mapped); + gui.gestures.push(ev); + + if(auto p = (const clap_host_params*)host.get_extension(&host, CLAP_EXT_PARAMS)) + p->request_flush(&host); + } + } + + // Drained from params.flush() and from process(): whichever the host + // calls first after a gesture. + void flush_gestures(const clap_output_events_t* out) + { + if constexpr(has_editor) + { + if(!out) + return; + gui.gestures.drain([&](const gesture_event& ev) { + if(ev.type == gesture_event::value) + { + clap_event_param_value e{}; + e.header.size = sizeof(e); + e.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + e.header.type = CLAP_EVENT_PARAM_VALUE; + e.param_id = ev.param_id; + e.note_id = -1; + e.port_index = -1; + e.channel = -1; + e.key = -1; + e.value = ev.value_mapped; + out->try_push(out, &e.header); + } + else + { + clap_event_param_gesture e{}; + e.header.size = sizeof(e); + e.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + e.header.type = ev.type == gesture_event::begin + ? CLAP_EVENT_PARAM_GESTURE_BEGIN + : CLAP_EVENT_PARAM_GESTURE_END; + e.param_id = ev.param_id; + out->try_push(out, &e.header); + } + }); + } + } + + // wrapped in functions so the class is complete for the self()-> lambdas (MSVC C2027) + static const clap_plugin_gui* get_gui() noexcept + { + static constexpr clap_plugin_gui gui_ext{ + .is_api_supported + = [](const clap_plugin* plugin, const char* api, bool is_floating) -> bool { + return !is_floating && api && std::string_view{api} == clap_native_window_api; + }, + + .get_preferred_api + = [](const clap_plugin* plugin, const char** api, bool* is_floating) -> bool { + *api = clap_native_window_api; + *is_floating = false; + return true; + }, + + .create + = [](const clap_plugin* plugin, const char* api, bool is_floating) -> bool { + return self(plugin)->gui_create(api, is_floating); + }, + + .destroy + = [](const clap_plugin* plugin) -> void { self(plugin)->gui_destroy(); }, + + .set_scale = [](const clap_plugin* plugin, double scale) -> bool { + auto& p = *self(plugin); + p.gui.scale = scale > 0. ? scale : 1.; + // Applied at open through gui_parent.scale; forwarded live when the + // editor supports rescaling. + if constexpr(requires { p.gui.editor->set_scale(scale); }) + { + if(p.gui.editor) + p.gui.editor->set_scale(p.gui.scale); + } + return true; + }, + + .get_size + = [](const clap_plugin* plugin, uint32_t* width, uint32_t* height) -> bool { + return self(plugin)->gui_get_size(width, height); + }, + + .can_resize = [](const clap_plugin* plugin) -> bool { return false; }, + + .get_resize_hints + = [](const clap_plugin* plugin, clap_gui_resize_hints* hints) -> bool { + return false; + }, + + .adjust_size + = [](const clap_plugin* plugin, uint32_t* width, uint32_t* height) -> bool { + return self(plugin)->gui_get_size(width, height); + }, + + .set_size + = [](const clap_plugin* plugin, uint32_t width, uint32_t height) -> bool { + uint32_t w{}, h{}; + return self(plugin)->gui_get_size(&w, &h) && w == width && h == height; + }, + + .set_parent + = [](const clap_plugin* plugin, const clap_window* window) -> bool { + return self(plugin)->gui_set_parent(window); + }, + + .set_transient + = [](const clap_plugin* plugin, const clap_window* window) -> bool { + return false; + }, + + .suggest_title = [](const clap_plugin* plugin, const char* title) -> void {}, + + .show = [](const clap_plugin* plugin) -> bool { return true; }, + + .hide = [](const clap_plugin* plugin) -> bool { return true; }}; + return &gui_ext; + } + + static const clap_plugin_timer_support* get_timer_support() noexcept + { + static constexpr clap_plugin_timer_support timer_ext{ + .on_timer = [](const clap_plugin* plugin, clap_id timer_id) -> void { + self(plugin)->gui_on_timer(timer_id); + }}; + return &timer_ext; + } + static const clap_plugin_params* get_params() noexcept { static constexpr clap_plugin_params params{ @@ -641,7 +972,21 @@ struct SimpleAudioEffect : clap_plugin .flush = [](const clap_plugin* plugin, const clap_input_events_t* input_parameter_changes, - const clap_output_events_t* output_parameter_changes) -> void {}}; + const clap_output_events_t* output_parameter_changes) -> void { + auto& p = *self(plugin); + if(input_parameter_changes) + { + const auto N = input_parameter_changes->size(input_parameter_changes); + for(uint32_t i = 0; i < N; i++) + { + const auto* ev + = input_parameter_changes->get(input_parameter_changes, i); + if(ev && ev->type == CLAP_EVENT_PARAM_VALUE) + p.process_param(*(const clap_event_param_value*)ev); + } + } + p.flush_gestures(output_parameter_changes); + }}; return ¶ms; } diff --git a/include/avnd/binding/clap/gui.hpp b/include/avnd/binding/clap/gui.hpp new file mode 100644 index 00000000..f63cd627 --- /dev/null +++ b/include/avnd/binding/clap/gui.hpp @@ -0,0 +1,105 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * clap.gui + clap.timer-support glue (CUSTOM_UI_PLAN.md §7.3). + * + * Editor resolution, in order: + * - T::ui::window (avnd::gui_windowed_ui): author brings any framework; + * - otherwise, when built with the soft UI backend (AVND_CLAP_UI), a + * declarative `struct ui` gets the reference pugl+Nuklear+canvas_ity + * editor; + * - otherwise no editor: the extension is not exposed. + * + * Automation gestures flow: editor (UI thread) -> gesture queue -> + * host params request_flush -> params.flush emits + * CLAP_EVENT_PARAM_GESTURE_BEGIN / PARAM_VALUE / GESTURE_END. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace avnd_clap +{ +template +using clap_editor_t = avnd::ui_editor_t; + +template +static constexpr bool clap_has_editor = avnd::has_ui_editor; + +#if defined(_WIN32) +static constexpr const char* clap_native_window_api = CLAP_WINDOW_API_WIN32; +static constexpr avnd::gui_api native_gui_api = avnd::gui_api::win32_hwnd; +#elif defined(__APPLE__) +static constexpr const char* clap_native_window_api = CLAP_WINDOW_API_COCOA; +static constexpr avnd::gui_api native_gui_api = avnd::gui_api::cocoa_nsview; +#else +static constexpr const char* clap_native_window_api = CLAP_WINDOW_API_X11; +static constexpr avnd::gui_api native_gui_api = avnd::gui_api::x11_window; +#endif + +// Pending automation-gesture events, filled on the UI thread, drained by +// params.flush (main or audio thread). +struct gesture_event +{ + enum kind : uint8_t + { + begin, + end, + value + } type{}; + clap_id param_id{}; + double value_mapped{}; // clap value space (map_control_to_double) +}; + +struct gesture_queue +{ + // Single producer (UI thread), so moodycamel's per-producer FIFO keeps + // begin/value/end ordered. enqueue() may allocate: fine on the UI thread. + moodycamel::ConcurrentQueue queue{128}; + + void push(gesture_event ev) { queue.enqueue(ev); } + + template + void drain(F&& f) + { + gesture_event ev; + while(queue.try_dequeue(ev)) + f(ev); + } +}; + +// ---- Message-bus transport: shared queue machinery ---- +template +using ui_to_proc_msg_t = avnd::ui_to_proc_msg_t; +template +using proc_to_ui_msg_t = avnd::proc_to_ui_msg_t; + +// GUI state carried by SimpleAudioEffect when an editor exists. +template +struct gui_state +{ + std::unique_ptr editor; + gesture_queue gestures; + clap_id timer_id{CLAP_INVALID_ID}; + double scale{1.}; + + AVND_NO_UNIQUE_ADDRESS avnd::bus_transport bus; +}; + +struct no_gui_state +{ +}; +} From 52c3d65578b3abec1262a2c65d7d5d97e304c79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:47:37 -0400 Subject: [PATCH 4/8] =?UTF-8?q?vintage:=20VST2=20editor=20support=20?= =?UTF-8?q?=E2=80=94=20EditOpen/EditClose/EditIdle/EditGetRect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same editor resolution and bus transport as the CLAP binding; automation gestures map to audioMaster BeginEdit/Automate/EndEdit and processor->UI messages drain on EditIdle and the editor frame hook. Co-Authored-By: Claude Fable 5 --- cmake/avendish.vintage.cmake | 4 +- include/avnd/binding/vintage/audio_effect.hpp | 10 + include/avnd/binding/vintage/dispatch.hpp | 7 + include/avnd/binding/vintage/gui.hpp | 234 ++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 include/avnd/binding/vintage/gui.hpp diff --git a/cmake/avendish.vintage.cmake b/cmake/avendish.vintage.cmake index dd6c2de9..74d90517 100644 --- a/cmake/avendish.vintage.cmake +++ b/cmake/avendish.vintage.cmake @@ -18,6 +18,7 @@ target_precompile_headers(Avendish_vintage_pch target_link_libraries(Avendish_vintage_pch PUBLIC + concurrentqueue DisableExceptions ) avnd_common_setup("" "Avendish_vintage_pch") @@ -101,6 +102,7 @@ function(avnd_make_vintage) ${AVND_FX_TARGET} PUBLIC Avendish::Avendish_vintage + concurrentqueue DisableExceptions ) @@ -108,7 +110,7 @@ function(avnd_make_vintage) endfunction() add_library(Avendish_vintage INTERFACE) -target_link_libraries(Avendish_vintage INTERFACE Avendish) +target_link_libraries(Avendish_vintage INTERFACE Avendish concurrentqueue) add_library(Avendish::Avendish_vintage ALIAS Avendish_vintage) target_sources(Avendish PRIVATE diff --git a/include/avnd/binding/vintage/audio_effect.hpp b/include/avnd/binding/vintage/audio_effect.hpp index 55029aa9..6e8dfcfe 100644 --- a/include/avnd/binding/vintage/audio_effect.hpp +++ b/include/avnd/binding/vintage/audio_effect.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -38,6 +39,8 @@ struct SimpleAudioEffect : vintage::Effect AVND_NO_UNIQUE_ADDRESS Controls controls; + AVND_NO_UNIQUE_ADDRESS gui_state_t gui; + AVND_NO_UNIQUE_ADDRESS ProcessorSetup processorSetup; AVND_NO_UNIQUE_ADDRESS process_adapter processor; @@ -73,6 +76,8 @@ struct SimpleAudioEffect : vintage::Effect Effect::flags |= EffectFlags::CanDoubleReplacing; if constexpr(avnd::midi_input_introspection::size > 0) Effect::flags |= EffectFlags::IsSynth; + if constexpr(avnd::has_ui_editor) + Effect::flags |= EffectFlags::HasEditor; Effect::ioRatio = 1.; Effect::object = this; @@ -186,6 +191,11 @@ struct SimpleAudioEffect : vintage::Effect // Before processing starts, we copy all our atomics back into the struct controls.write(effect); + // Drain UI → processor bus messages queued by the editor -- after the + // atomics so a process_message() writing control values wins over the + // mirror for this block. + gui_drain_to_processor(*this); + // Actual processing using fp_t = std::decay_t; processor.process( diff --git a/include/avnd/binding/vintage/dispatch.hpp b/include/avnd/binding/vintage/dispatch.hpp index 92240d6c..ca90e287 100644 --- a/include/avnd/binding/vintage/dispatch.hpp +++ b/include/avnd/binding/vintage/dispatch.hpp @@ -2,6 +2,7 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include #include #include @@ -270,6 +271,12 @@ intptr_t default_dispatch( } case EffectOpcodes::GetApiVersion: // 58 return Constants::ApiVersion; + + case EffectOpcodes::EditGetRect: // 13 + case EffectOpcodes::EditOpen: // 14 + case EffectOpcodes::EditClose: // 15 + case EffectOpcodes::EditIdle: // 19 + return gui_dispatch(object, opcode, index, value, ptr, opt); case EffectOpcodes::CanDo: // 51 { const std::string_view cando diff --git a/include/avnd/binding/vintage/gui.hpp b/include/avnd/binding/vintage/gui.hpp new file mode 100644 index 00000000..d0efa00d --- /dev/null +++ b/include/avnd/binding/vintage/gui.hpp @@ -0,0 +1,234 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * VST2 editor glue (CUSTOM_UI_PLAN.md §7.1): maps the vintage EditGetRect / + * EditOpen / EditClose / EditIdle opcodes onto an avnd::gui_windowed_ui + * editor (author-provided T::ui::window, or the soft pugl editor for + * declarative UIs). See binding/ui/editor.hpp for editor resolution. + * + * Parameter flow: the editor writes controls directly and mirrors them into + * the atomic Controls<> array (which the audio thread applies each block via + * controls.write()); automation gestures go to the host through + * audioMaster BeginEdit / Automate / EndEdit. Bus messages ride the shared + * lock-free transport: UI→processor drained at the start of process(), + * processor→UI drained on the editor's 30 Hz frame hook and on EditIdle. + */ + +#include +#include +#include +#include +#include + +#include + +namespace vintage +{ +template +struct gui_state +{ + std::unique_ptr> editor; + vintage::Rect rect{}; + AVND_NO_UNIQUE_ADDRESS avnd::bus_transport bus; +}; + +struct no_gui_state +{ +}; + +template +using gui_state_t + = std::conditional_t, gui_state, no_gui_state>; + +// ---- Host gesture protocol (UI thread) ---- +template +static avnd::gui_host gui_make_host(Self& self) +{ + using T = typename Self::effect_type; + avnd::gui_host h; + h.ctx = &self; + h.begin_edit = [](void* ctx, int param) { + auto& s = *static_cast(ctx); + s.request(HostOpcodes::BeginEdit, param, 0, nullptr, 0.f); + }; + h.perform_edit = [](void* ctx, int param, double normalized) { + auto& s = *static_cast(ctx); + // Mirror into the atomics so the audio thread's controls.write() applies + // the change instead of stomping it with the previous value. + if constexpr(requires { s.controls.parameters[param]; }) + if(param >= 0 + && param < std::decay_t::parameter_count) + s.controls.parameters[param].store( + (float)normalized, std::memory_order_release); + s.request(HostOpcodes::Automate, param, 0, nullptr, (float)normalized); + }; + h.end_edit = [](void* ctx, int param) { + auto& s = *static_cast(ctx); + s.request(HostOpcodes::EndEdit, param, 0, nullptr, 0.f); + }; + return h; +} + +// ---- Editor lifecycle ---- +template +static void gui_drain_to_ui(Self& self) +{ + using T = typename Self::effect_type; + if constexpr(avnd::processor_sends_to_ui) + { + if(self.gui.editor) + { + avnd::any_proc_to_ui_msg_t msg; + while(self.gui.bus.to_ui.queue.try_dequeue(msg)) + { + if constexpr(requires { self.gui.editor->runtime(); }) + self.gui.editor->runtime().deliver_to_ui(std::move(msg)); + else if constexpr(requires { self.gui.editor->process_message(msg); }) + self.gui.editor->process_message(std::move(msg)); + } + } + } +} + +// Called from the audio thread at the start of each block. +template +static void gui_drain_to_processor(Self& self) +{ + using T = typename Self::effect_type; + if constexpr(avnd::has_ui_editor && avnd::ui_sends_to_processor) + { + if constexpr(requires { + self.effect.effect.process_message( + std::declval>()); + }) + { + avnd::any_ui_to_proc_msg_t msg; + while(self.gui.bus.to_processor.queue.try_dequeue(msg)) + self.effect.effect.process_message(std::move(msg)); + } + } +} + +// processor → UI: always enqueue (send_message must be callable whether or +// not an editor is open). Re-installed after editor creation because the +// soft runtime's constructor wires a synchronous default. +template +static void gui_wire_bus(Self& self) +{ + using T = typename Self::effect_type; + if constexpr(avnd::has_ui_editor && avnd::processor_sends_to_ui) + { + if constexpr(requires { self.effect.effect.send_message = nullptr; }) + { + self.effect.effect.send_message = [&self](auto&& msg) { + self.gui.bus.to_ui.queue.try_enqueue(std::forward(msg)); + }; + } + } +} + +// Hosts may call EditGetRect before EditOpen: create the editor on demand. +template +static void gui_ensure(Self& self) +{ + using T = typename Self::effect_type; + if constexpr(avnd::has_ui_editor) + { + if(self.gui.editor) + return; + self.gui.editor = avnd::make_ui_editor(self.effect); + + if constexpr(requires { self.gui.editor->runtime(); }) + { + if constexpr(avnd::has_gui_to_processor_bus) + { + self.gui.editor->runtime().set_bus_to_processor([&self](auto&& msg) { + self.gui.bus.to_processor.queue.enqueue( + std::forward(msg)); + }); + } + } + else if constexpr(avnd::window_sends_to_processor) + { + if constexpr(requires { self.gui.editor->send_message; }) + { + self.gui.editor->send_message = [&self](auto&& msg) { + self.gui.bus.to_processor.queue.enqueue( + std::forward(msg)); + }; + } + } + gui_wire_bus(self); + if constexpr(requires { self.gui.editor->on_frame; }) + { + self.gui.editor->on_frame = [&self] { gui_drain_to_ui(self); }; + } + } +} + +// ---- Opcode handlers, called from default_dispatch ---- +template +static intptr_t gui_dispatch( + Self& self, EffectOpcodes opcode, int32_t index, intptr_t value, void* ptr, + float opt) +{ + using T = typename Self::effect_type; + if constexpr(!avnd::has_ui_editor) + { + return 0; + } + else + { + switch(opcode) + { + case EffectOpcodes::EditGetRect: { + if(!ptr) + return 0; + gui_ensure(self); + const auto [w, h] = self.gui.editor->size(); + self.gui.rect + = {.top = 0, .left = 0, .bottom = int16_t(h), .right = int16_t(w)}; + *reinterpret_cast(ptr) = &self.gui.rect; + return 1; + } + + case EffectOpcodes::EditOpen: { + gui_ensure(self); + avnd::gui_parent parent{.handle = ptr, .scale = 1.}; +#if defined(_WIN32) + parent.api = avnd::gui_api::win32_hwnd; +#elif defined(__APPLE__) + parent.api = avnd::gui_api::cocoa_nsview; +#else + parent.api = avnd::gui_api::x11_window; +#endif + self.gui.editor->open(parent, gui_make_host(self)); + return 1; + } + + case EffectOpcodes::EditClose: { + if(self.gui.editor) + { + self.gui.editor->close(); + self.gui.editor.reset(); + } + return 1; + } + + case EffectOpcodes::EditIdle: { + if(self.gui.editor) + { + gui_drain_to_ui(self); + self.gui.editor->idle(); + } + return 1; + } + + default: + return 0; + } + } +} +} From a5c2034c26408af52fbe2def23a6bed6dbd848a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:48:22 -0400 Subject: [PATCH 5/8] =?UTF-8?q?vst3:=20editor=20+=20message=20bus=20?= =?UTF-8?q?=E2=80=94=20IPlugView,=20IMessage=20transport,=20view=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plug_view drives an avnd::gui_windowed_ui editor: controller-side model instance, IComponentHandler begin/perform/endEdit gestures, IPlugViewContentScaleSupport, and a UI-thread tick (Win32 SetTimer / host IRunLoop on Linux / CFRunLoopTimer on macOS) that runs editor->idle() and the bus pump. - The message bus crosses the component/controller split via IMessage with the pfr serializer; IMessage is only allocated/sent on the UI thread (see the bus.hpp header comment for the pump protocol), the audio thread only enqueues. - The prototype defines the plug-view + Linux windowing IIDs (IRunLoop, ITimerHandler, IEventHandler): an unresolved reference to any of them would pull commoniids.cpp.o out of libsdk_common.a, which then collides with every DEF_CLASS_IID under GNU ld. Co-Authored-By: Claude Fable 5 --- cmake/avendish.vst3.cmake | 2 + include/avnd/binding/vst3/bus.hpp | 156 ++++++ include/avnd/binding/vst3/component.hpp | 78 +++ .../avnd/binding/vst3/connection_point.hpp | 6 + include/avnd/binding/vst3/controller.hpp | 97 +++- include/avnd/binding/vst3/prototype.cpp.in | 16 + include/avnd/binding/vst3/view.hpp | 449 ++++++++++++++++++ 7 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 include/avnd/binding/vst3/bus.hpp create mode 100644 include/avnd/binding/vst3/view.hpp diff --git a/cmake/avendish.vst3.cmake b/cmake/avendish.vst3.cmake index 91fe1f8c..02a31091 100644 --- a/cmake/avendish.vst3.cmake +++ b/cmake/avendish.vst3.cmake @@ -161,8 +161,10 @@ function(avnd_make_vst3) PUBLIC Avendish::Avendish_vst3 sdk_common pluginterfaces + concurrentqueue DisableExceptions ) + if(APPLE) find_library(COREFOUNDATION_FK CoreFoundation) target_link_libraries( diff --git a/include/avnd/binding/vst3/bus.hpp b/include/avnd/binding/vst3/bus.hpp new file mode 100644 index 00000000..3cd3f515 --- /dev/null +++ b/include/avnd/binding/vst3/bus.hpp @@ -0,0 +1,156 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * UI <-> processor message bus over VST3 IMessage (CUSTOM_UI_PLAN.md §8): + * unlike CLAP/VST2 the editor lives with the edit controller, a different + * object from the processing component (possibly out-of-process), so bus + * payloads cross through the host's connection points. + * + * Payloads are serialized with avnd::bus_serial (trivially-copyable types, + * std::string / std::vector members, aggregates thereof). Messages the + * serializer cannot handle keep the previous behaviour: that direction of + * the bus is disabled and the plug-in stays view-local. + * + * Threading: IHostApplication::createInstance and IConnectionPoint::notify + * are [UI-thread] per the SDK. The processor therefore never sends from + * process(): it enqueues into a preallocated lock-free queue, and the + * controller's editor timer sends a small "pump" message through the + * connection point. The component's notify() runs on the UI thread, where + * it drains the queue and sends the queued payloads back — spec-compliant + * on both ends, no allocation on the audio thread. + */ + +#include +#include + +#include +#include + +#include + +namespace stv3 +{ +static constexpr const char* bus_ui_to_processor_id = "avnd_ui_to_processor"; +static constexpr const char* bus_processor_to_ui_id = "avnd_processor_to_ui"; +static constexpr const char* bus_pump_id = "avnd_bus_pump"; +static constexpr const char* bus_payload_key = "avnd_payload"; + +namespace detail +{ +template +static constexpr bool busable = avnd::bus_serial::serializable; +} + +template +static constexpr bool bus_to_processor_enabled + = avnd::ui_sends_to_processor + && detail::busable>; + +template +static constexpr bool bus_to_ui_enabled + = avnd::processor_sends_to_ui + && detail::busable>; + +// Send a message through a connection point. Allocates the IMessage from +// the host application found in `context`. [UI-thread] only. +template +inline bool send_bus_message( + Steinberg::FUnknown* context, Steinberg::Vst::IConnectionPoint* peer, + const char* message_id, const Msg& msg) +{ + using namespace Steinberg; + using namespace Steinberg::Vst; + if(!context || !peer) + return false; + + IHostApplication* app{}; + if(context->queryInterface(IHostApplication::iid, (void**)&app) != kResultOk + || !app) + return false; + + TUID iid; + memcpy(iid, IMessage::iid, sizeof(TUID)); + IMessage* m{}; + const bool ok + = app->createInstance(iid, iid, (void**)&m) == kResultOk && m != nullptr; + if(ok) + { + m->setMessageID(message_id); + if(auto* attrs = m->getAttributes()) + { + if constexpr(std::is_trivially_copyable_v) + { + attrs->setBinary(bus_payload_key, &msg, sizeof(Msg)); + } + else + { + const auto bytes = avnd::bus_serial::to_bytes(msg); + attrs->setBinary(bus_payload_key, bytes.data(), (Steinberg::uint32)bytes.size()); + } + } + peer->notify(m); + m->release(); + } + app->release(); + return ok; +} + +// Parameterless notification (e.g. the bus pump). [UI-thread] only. +inline bool send_empty_message( + Steinberg::FUnknown* context, Steinberg::Vst::IConnectionPoint* peer, + const char* message_id) +{ + using namespace Steinberg; + using namespace Steinberg::Vst; + if(!context || !peer) + return false; + IHostApplication* app{}; + if(context->queryInterface(IHostApplication::iid, (void**)&app) != kResultOk + || !app) + return false; + TUID iid; + memcpy(iid, IMessage::iid, sizeof(TUID)); + IMessage* m{}; + const bool ok + = app->createInstance(iid, iid, (void**)&m) == kResultOk && m != nullptr; + if(ok) + { + m->setMessageID(message_id); + peer->notify(m); + m->release(); + } + app->release(); + return ok; +} + +// Extract a message of the given type; returns false when the IMessage is +// not ours or the payload does not decode. +template +inline bool read_bus_message( + Steinberg::Vst::IMessage& m, const char* message_id, Msg& out) +{ + using namespace Steinberg; + if(!m.getMessageID() || strcmp(m.getMessageID(), message_id) != 0) + return false; + auto* attrs = m.getAttributes(); + if(!attrs) + return false; + const void* data{}; + Steinberg::uint32 size{}; + if(attrs->getBinary(bus_payload_key, data, size) != kResultOk || !data) + return false; + if constexpr(std::is_trivially_copyable_v) + { + if(size != sizeof(Msg)) + return false; + memcpy(&out, data, sizeof(Msg)); + return true; + } + else + { + return avnd::bus_serial::from_bytes(data, size, out); + } +} +} diff --git a/include/avnd/binding/vst3/component.hpp b/include/avnd/binding/vst3/component.hpp index 88de85a6..f976617a 100644 --- a/include/avnd/binding/vst3/component.hpp +++ b/include/avnd/binding/vst3/component.hpp @@ -2,6 +2,7 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include #include #include @@ -74,6 +75,27 @@ struct Component final avnd::effect_container effect; + // UI -> processor bus messages, enqueued from notify() (main thread), + // drained in process(). See binding/vst3/bus.hpp. + struct no_bus_queue + { + }; + AVND_NO_UNIQUE_ADDRESS + std::conditional_t< + stv3::bus_to_processor_enabled, + avnd::ui_to_proc_queue>, no_bus_queue> + bus_to_processor; + + // processor -> UI bus messages: enqueued from process() (audio thread, + // preallocated, drops on overflow), sent through the connection point + // when the controller pumps us (notify() runs on the UI thread — the + // only thread allowed to allocate/send IMessage). + AVND_NO_UNIQUE_ADDRESS + std::conditional_t< + stv3::bus_to_ui_enabled, + avnd::proc_to_ui_queue>, no_bus_queue> + bus_to_ui; + AVND_NO_UNIQUE_ADDRESS avnd::process_adapter processor; AVND_NO_UNIQUE_ADDRESS avnd::midi_storage midi; @@ -111,6 +133,48 @@ struct Component final // Safe no-op handlers for worker.request / request_channels members: // an empty std::function call terminates under -fno-exceptions. avnd::wire_fallback_callbacks(effect); + + // processor -> UI bus: IMessage may only be allocated/sent on the UI + // thread, so the audio thread merely enqueues (preallocated queue, + // drops on overflow); the controller's editor timer pumps us and the + // queue is flushed from notify() below. + if constexpr(stv3::bus_to_ui_enabled) + { + if constexpr(requires { this->effect.effect.send_message = nullptr; }) + { + this->effect.effect.send_message = [this](const auto& msg) { + this->bus_to_ui.queue.try_enqueue(msg); + }; + } + } + } + + Steinberg::tresult on_message(Steinberg::Vst::IMessage& m) override + { + if constexpr(stv3::bus_to_processor_enabled) + { + avnd::any_ui_to_proc_msg_t msg; + if(stv3::read_bus_message(m, stv3::bus_ui_to_processor_id, msg)) + { + bus_to_processor.queue.enqueue(std::move(msg)); + return Steinberg::kResultOk; + } + } + if constexpr(stv3::bus_to_ui_enabled) + { + // Bus pump from the controller: we are on the UI thread — flush the + // processor's queued messages through the connection point. + if(m.getMessageID() && strcmp(m.getMessageID(), stv3::bus_pump_id) == 0) + { + avnd::any_proc_to_ui_msg_t msg; + while(bus_to_ui.queue.try_dequeue(msg)) + stv3::send_bus_message( + this->hostContext.get(), this->peerConnection.get(), + stv3::bus_processor_to_ui_id, msg); + return Steinberg::kResultOk; + } + } + return Steinberg::kResultFalse; } virtual ~Component() { } @@ -541,6 +605,20 @@ struct Component final using namespace Steinberg; using namespace Steinberg::Vst; + // Drain UI -> processor bus messages queued by notify() + if constexpr(stv3::bus_to_processor_enabled) + { + if constexpr(requires { + this->effect.effect.process_message( + std::declval>()); + }) + { + avnd::any_ui_to_proc_msg_t msg; + while(bus_to_processor.queue.try_dequeue(msg)) + this->effect.effect.process_message(std::move(msg)); + } + } + // Clear outputs this->midi.clear_outputs(effect); if constexpr(sizeof(control_buffers) > 1) diff --git a/include/avnd/binding/vst3/connection_point.hpp b/include/avnd/binding/vst3/connection_point.hpp index d69aa4eb..26a9dea3 100644 --- a/include/avnd/binding/vst3/connection_point.hpp +++ b/include/avnd/binding/vst3/connection_point.hpp @@ -45,6 +45,12 @@ class ConnectionPoint : public Steinberg::Vst::IConnectionPoint if(!message) return Steinberg::kInvalidArgument; + return on_message(*message); + } + + // Typed hook for subclasses (message-bus transport, ...) + virtual Steinberg::tresult on_message(Steinberg::Vst::IMessage&) + { return Steinberg::kResultFalse; } }; diff --git a/include/avnd/binding/vst3/controller.hpp b/include/avnd/binding/vst3/controller.hpp index 4aed48ea..b2ec2b9a 100644 --- a/include/avnd/binding/vst3/controller.hpp +++ b/include/avnd/binding/vst3/controller.hpp @@ -1,5 +1,7 @@ #pragma once +#include #include +#include #include #include #include @@ -13,6 +15,8 @@ #include #include +#include +#include namespace stv3 { @@ -58,17 +62,87 @@ class Controller final return m_refcount; } +public: using inputs_t = typename avnd::inputs_type::type; inputs_t inputs_mirror{}; using inputs_info_t = avnd::parameter_input_introspection; static const constexpr int32_t parameter_count = inputs_info_t::size; -public: - Controller() { } + // Set by the plug view while it is alive: host-driven parameter changes + // are forwarded to the editor's model. + std::function ui_param_changed; + + // ---- Message bus over the host connection (binding/vst3/bus.hpp) ---- + // UI thread -> processing component + template + void send_ui_message(const Msg& msg) + { + stv3::send_bus_message( + this->hostContext.get(), this->peerConnection.get(), + stv3::bus_ui_to_processor_id, msg); + } + + // Ask the component (UI thread) to flush its queued processor->UI + // messages through the connection point; see the pump protocol notes in + // binding/vst3/bus.hpp. Called from the view's timer while an editor is + // open. + void send_bus_pump() + { + stv3::send_empty_message( + this->hostContext.get(), this->peerConnection.get(), stv3::bus_pump_id); + } + + // Component -> UI: delivered to the active view + struct no_bus_handler + { + }; + static constexpr auto bus_handler_type() + { + if constexpr(stv3::bus_to_ui_enabled) + return std::type_identity< + std::function)>>{}; + else + return std::type_identity{}; + } + AVND_NO_UNIQUE_ADDRESS + typename decltype(bus_handler_type())::type ui_message_received{}; + + Steinberg::tresult on_message(Steinberg::Vst::IMessage& m) override + { + if constexpr(stv3::bus_to_ui_enabled) + { + avnd::any_proc_to_ui_msg_t msg; + if(stv3::read_bus_message(m, stv3::bus_processor_to_ui_id, msg)) + { + if(ui_message_received) + ui_message_received(std::move(msg)); + return Steinberg::kResultOk; + } + } + return Steinberg::kResultFalse; + } + + Controller() + { + // Give the mirror the declared initial values so hosts (and the editor) + // see them before any state load. + if constexpr(avnd::has_inputs) + { + inputs_info_t::for_all(inputs_mirror, [](C& field) { + if constexpr(avnd::has_range) + { + if constexpr(requires { field.value = avnd::get_range().init; }) + field.value = avnd::get_range().init; + } + }); + } + } virtual ~Controller(); + Steinberg::IPlugView* createView(const char* name) override; + int32 getParameterCount() override { return inputs_info_t::size; } Steinberg::tresult getParameterInfo(int32 paramIndex, ParameterInfo& info) override @@ -162,6 +236,9 @@ class Controller final }); } + if(ui_param_changed) + ui_param_changed(tag, value); + return Steinberg::kResultTrue; } @@ -262,3 +339,19 @@ Controller::~Controller() { } } + +#include + +namespace stv3 +{ +template +Steinberg::IPlugView* Controller::createView(const char* name) +{ + if constexpr(avnd::has_ui_editor) + { + if(name && std::string_view{name} == Steinberg::Vst::ViewType::kEditor) + return new stv3::plug_view>{*this}; + } + return nullptr; +} +} diff --git a/include/avnd/binding/vst3/prototype.cpp.in b/include/avnd/binding/vst3/prototype.cpp.in index 6e271b2a..2a8735d7 100644 --- a/include/avnd/binding/vst3/prototype.cpp.in +++ b/include/avnd/binding/vst3/prototype.cpp.in @@ -36,6 +36,22 @@ extern "C" AVND_EXPORTED_SYMBOL Steinberg::IPluginFactory* GetPluginFactory() // clang-format on namespace Steinberg { +DEF_CLASS_IID(IPlugView) +DEF_CLASS_IID(IPlugFrame) +DEF_CLASS_IID(IPlugViewContentScaleSupport) + +#if defined(__linux__) +// Define the Linux windowing IIDs here too: an unresolved reference to any +// of them would pull commoniids.cpp.o out of libsdk_common.a, which then +// collides with every definition in this file under GNU ld. +namespace Linux +{ +DEF_CLASS_IID(IEventHandler) +DEF_CLASS_IID(ITimerHandler) +DEF_CLASS_IID(IRunLoop) +} +#endif + namespace Vst { diff --git a/include/avnd/binding/vst3/view.hpp b/include/avnd/binding/vst3/view.hpp new file mode 100644 index 00000000..db4ee9a1 --- /dev/null +++ b/include/avnd/binding/vst3/view.hpp @@ -0,0 +1,449 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * VST3 IPlugView glue (CUSTOM_UI_PLAN.md §7.2), driving an + * avnd::gui_windowed_ui editor (author-provided T::ui::window or the soft + * pugl editor). See binding/ui/editor.hpp for editor resolution. + * + * The view owns a controller-side model instance (effect_container) that + * the editor renders and mutates; host automation reaches it through + * Controller::setParamNormalized -> ui_param_changed, and user edits reach + * the host through IComponentHandler begin/perform/endEdit with the same + * normalized mapping as getParamNormalized. + * + * Ticks: the view owns a 16 ms UI-thread timer while attached (Win32 + * SetTimer / host IRunLoop on Linux / CFRunLoopTimer on macOS). Each tick + * calls the editor's idle() (this is the clock Tier C editors are promised + * by the concept; the soft editor's pugl timer coexists harmlessly) and + * pumps the processor->UI message bus (see binding/vst3/bus.hpp). + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#if defined(_WIN32) +#if !defined(WIN32_LEAN_AND_MEAN) +#define WIN32_LEAN_AND_MEAN +#endif +#if !defined(NOMINMAX) +#define NOMINMAX +#endif +#include +#include +#elif defined(__APPLE__) +#include +#endif + +namespace stv3 +{ +template +class plug_view final + : public Steinberg::IPlugView + , public Steinberg::IPlugViewContentScaleSupport + , public stv3::refcount +{ + using tresult = Steinberg::tresult; + using ViewRect = Steinberg::ViewRect; + using ParamID = Steinberg::Vst::ParamID; + using inputs_info_t = avnd::parameter_input_introspection; + +public: + explicit plug_view(Controller& ctrl) + : controller{ctrl} + { + // addRef/release are private overrides in the controller; go through + // the public interface names. + controller_iface().addRef(); + + if constexpr(avnd::has_inputs) + { + avnd::init_controls(model); + // Adopt the controller's current parameter state + for(auto state : model.full_state()) + { + if constexpr(requires { + state.inputs = controller.inputs_mirror; + }) + state.inputs = controller.inputs_mirror; + } + } + + editor = avnd::make_ui_editor(model); + controller.ui_param_changed + = [this](ParamID tag, double value) { apply_param(tag, value); }; + + // Message bus: route through the host connection to the real + // processing component instead of the view-local model + // (binding/vst3/bus.hpp; trivially-copyable payloads only for now). + if constexpr(stv3::bus_to_processor_enabled) + { + if constexpr(requires { editor->runtime(); }) + { + editor->runtime().set_bus_to_processor( + [this](auto&& msg) { controller.send_ui_message(msg); }); + } + else if constexpr(requires { editor->send_message; }) + { + editor->send_message + = [this](auto&& msg) { controller.send_ui_message(msg); }; + } + } + if constexpr(stv3::bus_to_ui_enabled) + { + controller.ui_message_received = [this](auto msg) { + if(!editor) + return; + if constexpr(requires { editor->runtime(); }) + editor->runtime().deliver_to_ui(std::move(msg)); + else if constexpr(requires { editor->process_message(std::move(msg)); }) + editor->process_message(std::move(msg)); + }; + } + } + + ~plug_view() + { + stop_tick(); + controller.ui_param_changed = {}; + if constexpr(stv3::bus_to_ui_enabled) + controller.ui_message_received = {}; + if(editor) + editor->close(); + editor.reset(); + if(frame) + frame->release(); + controller_iface().release(); + } + + Steinberg::Vst::IEditController& controller_iface() noexcept + { + return controller; + } + + plug_view(const plug_view&) = delete; + plug_view& operator=(const plug_view&) = delete; + + // ---- FUnknown ---- + tresult queryInterface(const Steinberg::TUID iid, void** obj) override + { + QUERY_INTERFACE(iid, obj, Steinberg::FUnknown::iid, Steinberg::IPlugView); + QUERY_INTERFACE(iid, obj, Steinberg::IPlugView::iid, Steinberg::IPlugView); + QUERY_INTERFACE( + iid, obj, Steinberg::IPlugViewContentScaleSupport::iid, + Steinberg::IPlugViewContentScaleSupport); + *obj = nullptr; + return Steinberg::kNoInterface; + } + Steinberg::uint32 addRef() override { return ++m_refcount; } + Steinberg::uint32 release() override + { + if(--m_refcount == 0) + { + delete this; + return 0; + } + return m_refcount; + } + + // ---- IPlugView ---- + tresult isPlatformTypeSupported(Steinberg::FIDString type) override + { + if(!type) + return Steinberg::kInvalidArgument; + const std::string_view t = type; +#if defined(_WIN32) + if(t == Steinberg::kPlatformTypeHWND) + return Steinberg::kResultTrue; +#elif defined(__APPLE__) + if(t == Steinberg::kPlatformTypeNSView) + return Steinberg::kResultTrue; +#else + if(t == Steinberg::kPlatformTypeX11EmbedWindowID) + return Steinberg::kResultTrue; +#endif + return Steinberg::kResultFalse; + } + + tresult attached(void* parent, Steinberg::FIDString type) override + { + if(isPlatformTypeSupported(type) != Steinberg::kResultTrue) + return Steinberg::kInvalidArgument; + if(!editor) + return Steinberg::kResultFalse; + + avnd::gui_parent p{.handle = parent, .scale = m_scale}; +#if defined(_WIN32) + p.api = avnd::gui_api::win32_hwnd; +#elif defined(__APPLE__) + p.api = avnd::gui_api::cocoa_nsview; +#else + p.api = avnd::gui_api::x11_window; +#endif + editor->open(p, make_host()); + start_tick(); + return Steinberg::kResultOk; + } + + tresult removed() override + { + stop_tick(); + if(editor) + editor->close(); + return Steinberg::kResultOk; + } + + tresult onWheel(float) override { return Steinberg::kResultFalse; } + tresult onKeyDown(Steinberg::char16, Steinberg::int16, Steinberg::int16) override + { + return Steinberg::kResultFalse; + } + tresult onKeyUp(Steinberg::char16, Steinberg::int16, Steinberg::int16) override + { + return Steinberg::kResultFalse; + } + + tresult getSize(ViewRect* size) override + { + if(!size || !editor) + return Steinberg::kInvalidArgument; + const auto [w, h] = editor->size(); + *size = ViewRect{0, 0, (Steinberg::int32)w, (Steinberg::int32)h}; + return Steinberg::kResultTrue; + } + + tresult onSize(ViewRect*) override { return Steinberg::kResultTrue; } + tresult onFocus(Steinberg::TBool) override { return Steinberg::kResultTrue; } + + tresult setFrame(Steinberg::IPlugFrame* f) override + { + if(frame) + frame->release(); + frame = f; + if(frame) + frame->addRef(); + return Steinberg::kResultTrue; + } + + tresult canResize() override { return Steinberg::kResultFalse; } + + // ---- IPlugViewContentScaleSupport ---- + tresult setContentScaleFactor( + Steinberg::IPlugViewContentScaleSupport::ScaleFactor factor) override + { + m_scale = factor > 0.f ? factor : 1.f; + if constexpr(requires { editor->set_scale(m_scale); }) + { + if(editor) + { + editor->set_scale(m_scale); + return Steinberg::kResultTrue; + } + } + return Steinberg::kResultFalse; + } + + tresult checkSizeConstraint(ViewRect* rect) override + { + if(!rect || !editor) + return Steinberg::kInvalidArgument; + const auto [w, h] = editor->size(); + rect->right = rect->left + w; + rect->bottom = rect->top + h; + return Steinberg::kResultTrue; + } + +private: + avnd::gui_host make_host() + { + avnd::gui_host h; + h.ctx = this; + h.begin_edit = [](void* ctx, int param) { + auto& self = *static_cast(ctx); + if(auto* handler = self.controller.componentHandler; + handler && param >= 0 && param < inputs_info_t::size) + handler->beginEdit(inputs_info_t::index_map[param]); + }; + h.perform_edit = [](void* ctx, int param, double normalized) { + auto& self = *static_cast(ctx); + if(param < 0 || param >= inputs_info_t::size) + return; + const ParamID tag = inputs_info_t::index_map[param]; + // Keep the controller mirror current so getParamNormalized is fresh + if constexpr(avnd::has_inputs) + { + inputs_info_t::for_nth_raw( + self.controller.inputs_mirror, tag, [&](C& field) { + if constexpr(requires { avnd::map_control_from_01(normalized); }) + assign_if_assignable( + field.value, avnd::map_control_from_01(normalized)); + }); + } + if(auto* handler = self.controller.componentHandler) + handler->performEdit(tag, normalized); + }; + h.end_edit = [](void* ctx, int param) { + auto& self = *static_cast(ctx); + if(auto* handler = self.controller.componentHandler; + handler && param >= 0 && param < inputs_info_t::size) + handler->endEdit(inputs_info_t::index_map[param]); + }; + return h; + } + + // Host automation / preset load -> update the view-side model + void apply_param(ParamID tag, double normalized) + { + if constexpr(avnd::has_inputs) + { + for(auto state : model.full_state()) + { + inputs_info_t::for_nth_raw(state.inputs, tag, [&](C& field) { + if constexpr(requires { avnd::map_control_from_01(normalized); }) + assign_if_assignable( + field.value, avnd::map_control_from_01(normalized)); + }); + } + if constexpr(requires { editor->runtime(); }) + if(editor) + editor->runtime().mark_dirty(); + } + } + + // ---- UI-thread tick: editor idle() + processor->UI bus pump ---- + static constexpr int tick_ms = 16; + + void on_tick() + { + if(editor) + editor->idle(); + if constexpr(stv3::bus_to_ui_enabled) + controller.send_bus_pump(); + } + +#if defined(_WIN32) + // HWND-less SetTimer: the TimerProc trampoline resolves the view through + // a UI-thread-only id map. + static std::map& timer_map() + { + static std::map m; + return m; + } + static void CALLBACK timer_proc(HWND, UINT, UINT_PTR id, DWORD) + { + auto& m = timer_map(); + if(auto it = m.find(id); it != m.end()) + it->second->on_tick(); + } + void start_tick() + { + if(m_timer) + return; + m_timer = SetTimer(nullptr, 0, tick_ms, &plug_view::timer_proc); + if(m_timer) + timer_map()[m_timer] = this; + } + void stop_tick() + { + if(m_timer) + { + KillTimer(nullptr, m_timer); + timer_map().erase(m_timer); + m_timer = 0; + } + } + UINT_PTR m_timer{}; +#elif defined(__APPLE__) + static void timer_cb(CFRunLoopTimerRef, void* info) + { + static_cast(info)->on_tick(); + } + void start_tick() + { + if(m_timer) + return; + CFRunLoopTimerContext ctx{}; + ctx.info = this; + m_timer = CFRunLoopTimerCreate( + nullptr, CFAbsoluteTimeGetCurrent() + tick_ms / 1000., tick_ms / 1000., 0, + 0, &plug_view::timer_cb, &ctx); + if(m_timer) + CFRunLoopAddTimer(CFRunLoopGetMain(), m_timer, kCFRunLoopCommonModes); + } + void stop_tick() + { + if(m_timer) + { + CFRunLoopTimerInvalidate(m_timer); + CFRelease(m_timer); + m_timer = nullptr; + } + } + CFRunLoopTimerRef m_timer{}; +#else + // Host-provided run loop (queried from the IPlugFrame, per the VST3 + // Linux windowing contract). + struct timer_handler final : Steinberg::Linux::ITimerHandler + { + plug_view* self{}; + tresult queryInterface(const Steinberg::TUID iid, void** obj) override + { + QUERY_INTERFACE( + iid, obj, Steinberg::FUnknown::iid, Steinberg::Linux::ITimerHandler); + QUERY_INTERFACE( + iid, obj, Steinberg::Linux::ITimerHandler::iid, + Steinberg::Linux::ITimerHandler); + *obj = nullptr; + return Steinberg::kNoInterface; + } + // Lifetime is the owning view's + Steinberg::uint32 addRef() override { return 2; } + Steinberg::uint32 release() override { return 1; } + void onTimer() override { self->on_tick(); } + }; + void start_tick() + { + if(m_runloop || !frame) + return; + Steinberg::Linux::IRunLoop* rl{}; + if(frame->queryInterface(Steinberg::Linux::IRunLoop::iid, (void**)&rl) + == Steinberg::kResultOk + && rl) + { + m_tick_handler.self = this; + rl->registerTimer(&m_tick_handler, tick_ms); + m_runloop = rl; + } + } + void stop_tick() + { + if(m_runloop) + { + m_runloop->unregisterTimer(&m_tick_handler); + m_runloop->release(); + m_runloop = nullptr; + } + } + timer_handler m_tick_handler; + Steinberg::Linux::IRunLoop* m_runloop{}; +#endif + + Controller& controller; + avnd::effect_container model; + std::unique_ptr> editor; + Steinberg::IPlugFrame* frame{}; + double m_scale{1.}; +}; +} From e03f53830e1f7011965bd694c43bbace0ce8f517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 09:49:39 -0400 Subject: [PATCH 6/8] examples+tests+docs: Tier C author-provided editor end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomUiWindow: a gain with a hand-rolled editor (raw Win32 + GDI on Windows, raw Xlib on Linux) through the avnd::gui_windowed_ui seam — no UI library at all, so the contract is the whole story. Tested through the CLAP binding by tests/ui/test_custom_ui_window.cpp: embed in a real parent window, injected drag, gesture flow, message-bus round trip. tests/ui/gui_test_host.hpp is the shared Win32/X11 host layer (parent window, event pump with per-tick host callback, child discovery, synthetic input, PPM capture) that the soft-editor tests reuse later. Co-Authored-By: Claude Fable 5 --- book/src/advanced/ui.md | 27 +- cmake/avendish.examples.cmake | 22 ++ cmake/avendish.tests.cmake | 35 ++ examples/Advanced/UI/CustomUiWindow.hpp | 439 ++++++++++++++++++++++++ tests/ui/gui_test_host.hpp | 380 ++++++++++++++++++++ tests/ui/test_custom_ui_window.cpp | 252 ++++++++++++++ 6 files changed, 1153 insertions(+), 2 deletions(-) create mode 100644 examples/Advanced/UI/CustomUiWindow.hpp create mode 100644 tests/ui/gui_test_host.hpp create mode 100644 tests/ui/test_custom_ui_window.cpp diff --git a/book/src/advanced/ui.md b/book/src/advanced/ui.md index d8c69b29..b4aef688 100644 --- a/book/src/advanced/ui.md +++ b/book/src/advanced/ui.md @@ -3,7 +3,7 @@ We have seen so far that we can specify widgets for our controls. Multiple back-ends may render these widgets in various ways. This is already a good start for making user interfaces, but most media systems generally have more specific user interface needs. -Avendish allows three levels of UI definition: +Avendish allows four levels of UI definition: 1. Automatic: nothing to do, all the widgets corresponding to inputs and outputs of the processor will be generated automatically in a list. This is not pretty but sufficient for many simple cases. For instance, here is how some Avendish plug-ins render in *ossia score*. @@ -22,4 +22,27 @@ Avendish allows three levels of UI definition: > Supported bindings: ossia -![Basic UI](images/ui-image.gif) \ No newline at end of file +![Basic UI](images/ui-image.gif) + +4. Shipping an entirely custom editor written with **any UI framework**, through + the `ui::window` escape hatch: + +```cpp +struct ui { + struct window { + void open(avnd::gui_parent parent, avnd::gui_host host); // host's native window handle + void close(); + void idle(); // host UI-thread tick + std::pair size() const; + }; +}; +``` + +The bindings hand `open()` the host-provided parent window (HWND / NSView / +X11 window) and a set of automation-gesture callbacks +(`begin_edit` / `perform_edit` / `end_edit`); anything can be created inside — +Dear ImGui, Qt, JUCE, or raw platform code. See +`examples/Advanced/UI/CustomUiWindow.hpp` for a complete plug-in whose editor +is a plain Win32 window, built as CLAP, VST2 and VST3 from the same file. + +> Supported bindings: CLAP, VST2, VST3 diff --git a/cmake/avendish.examples.cmake b/cmake/avendish.examples.cmake index ccb83e5c..f448ae78 100644 --- a/cmake/avendish.examples.cmake +++ b/cmake/avendish.examples.cmake @@ -154,6 +154,28 @@ avnd_make_audioplug( C_NAME avnd_midi ) +# Tier C custom-UI example: the plug-in ships its own editor (raw Win32 / +# raw Xlib) through the avnd::gui_windowed_ui seam instead of the soft editor. +avnd_make_audioplug( + TARGET CustomUiWindow + MAIN_FILE examples/Advanced/UI/CustomUiWindow.hpp + MAIN_CLASS examples::CustomUiWindow + C_NAME avnd_custom_ui_window +) +# The example's Xlib editor must be linked into the plug-in modules +# themselves: a host has no reason to have libX11 loaded when it dlopens +# them. (Without X11 headers the example simply has no editor.) +if(UNIX AND NOT APPLE) + find_package(X11 QUIET) + if(X11_FOUND) + foreach(_avnd_backend clap vst3 vintage) + if(TARGET CustomUiWindow_${_avnd_backend}) + target_link_libraries(CustomUiWindow_${_avnd_backend} PRIVATE X11::X11) + endif() + endforeach() + endif() +endif() + # GCC segfaults with those two... if(NOT "${CMAKE_CXX_COMPILER_ID}" MATCHES "GNU") avnd_make_all( diff --git a/cmake/avendish.tests.cmake b/cmake/avendish.tests.cmake index 798d64ed..a5c85b37 100644 --- a/cmake/avendish.tests.cmake +++ b/cmake/avendish.tests.cmake @@ -38,6 +38,9 @@ if(BUILD_TESTING) avnd_add_static_test(test_introspection_rec tests/test_introspection_rec.cpp) avnd_add_static_test(test_predicate tests/test_predicate.cpp) avnd_add_static_test(test_vintage tests/tests_vintage.cpp) + if(TARGET concurrentqueue) # vintage gui glue's bus transport + target_link_libraries(test_vintage PRIVATE concurrentqueue) + endif() avnd_add_static_test(test_channels tests/tests_channels.cpp) avnd_add_static_test(test_function_reflection tests/tests_function_reflection.cpp) avnd_add_static_test(test_audioprocessor tests/test_audioprocessor.cpp) @@ -48,5 +51,37 @@ if(BUILD_TESTING) avnd_add_catch_test(test_gain tests/objects/gain.cpp) avnd_add_catch_test(test_patternal tests/objects/patternal.cpp) + + # The end-to-end editor tests need a real windowing system: Win32, or + # X11 (run under Xvfb in headless CI). + if(WIN32) + set(AVND_GUI_TESTS 1) + elseif(UNIX AND NOT APPLE) + find_package(X11 QUIET) + if(X11_FOUND) + set(AVND_GUI_TESTS 1) + else() + set(AVND_GUI_TESTS 0) + endif() + else() + set(AVND_GUI_TESTS 0) + endif() + + # Editor test hosts drive the plug-in's window from outside on X11. + function(avnd_gui_test_platform_libs theTarget) + if(UNIX AND NOT APPLE) + target_link_libraries("${theTarget}" PRIVATE X11::X11) + endif() + endfunction() + + # Tier C: the CustomUiWindow example's author-provided editor. + if(AVND_GUI_TESTS AND TARGET CustomUiWindow_clap) + avnd_add_catch_test(test_custom_ui_window tests/ui/test_custom_ui_window.cpp) + add_dependencies(test_custom_ui_window CustomUiWindow_clap) + target_include_directories(test_custom_ui_window PRIVATE ${CLAP_HEADER}) + avnd_gui_test_platform_libs(test_custom_ui_window) + target_compile_definitions(test_custom_ui_window PRIVATE + "AVND_TEST_CUSTOM_UI_CLAP_PATH=\"$\"") + endif() endif() diff --git a/examples/Advanced/UI/CustomUiWindow.hpp b/examples/Advanced/UI/CustomUiWindow.hpp new file mode 100644 index 00000000..773bff63 --- /dev/null +++ b/examples/Advanced/UI/CustomUiWindow.hpp @@ -0,0 +1,439 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * Tier C custom-UI example (CUSTOM_UI_PLAN.md §5): instead of the + * declarative `struct ui` rendered by the reference soft editor, the plug-in + * ships its own editor implementing the avnd::gui_windowed_ui shape: + * + * struct ui { + * struct window { + * void open(avnd::gui_parent, avnd::gui_host); + * void close(); + * void idle(); + * std::pair size() const; + * }; + * }; + * + * Any UI stack works behind this seam -- Dear ImGui, Qt, JUCE, ... This + * example deliberately uses no library at all: a raw Win32 child window + * painted with GDI, so the contract itself is the whole story: + * + * - open() receives the host's parent window handle and creates whatever + * the framework needs inside it; + * - parameter *reads* poll the effect model (live processor instance in + * CLAP/VST2, controller-side model in VST3), so host automation shows up + * without any extra plumbing; + * - parameter *writes* set the field, then drive the automation-gesture + * hooks on avnd::gui_host (flat parameter index, [0; 1] normalized) -- + * the bindings translate to CLAP gesture events / audioMasterAutomate / + * IComponentHandler; + * - ticks come from the host (clap.timer-support, effEditIdle) and from + * the host's message pump for frameworks with their own timers, like + * the SetTimer used here. + * + * Two hand-rolled implementations of the same contract: raw Win32 + GDI on + * Windows, raw Xlib on Linux (when the X11 headers are present). On other + * platforms the plug-in simply has no editor (the concept is not satisfied, + * bindings skip it). + */ + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#if !defined(WIN32_LEAN_AND_MEAN) +#define WIN32_LEAN_AND_MEAN 1 +#endif +#if !defined(NOMINMAX) +#define NOMINMAX 1 +#endif +#include +// windows.h macro hygiene: these collide with enumerator names in binding +// headers included after this file (e.g. vintage.hpp's key enums). +#undef DELETE +#undef ALTERNATE +#undef WINDING +#elif defined(__linux__) && __has_include() +// Pull Xutil.h too (it needs the Bool/Status macros): later headers in the +// same TU that want it (the soft UI blit) will then no-op on their include. +#include +#include +// Xlib macro hygiene: this header is included in the middle of plug-in TUs, +// so drop the macros that collide with everything (keep the event type and +// mask constants, they are namespaced enough in practice). +#undef None +#undef Bool +#undef True +#undef False +#undef Status +#undef Always +#undef Success +#endif + +#include +#include +#include +#include +#include + +namespace examples +{ +struct CustomUiWindow +{ + halp_meta(name, "Custom UI window") + halp_meta(c_name, "avnd_custom_ui_window") + halp_meta(category, "Demo") + halp_meta(author, "Avendish") + halp_meta(description, "Gain with a hand-rolled (raw Win32) editor") + halp_meta(uuid, "aef14f10-fb50-45e9-9dc6-c40c5e5fe790") + + struct ins + { + halp::knob_f32<"Gain", halp::range{.min = 0., .max = 1., .init = 0.5}> gain; + halp::dynamic_audio_bus<"In", float> audio; + } inputs; + + struct outs + { + halp::dynamic_audio_bus<"Out", float> audio; + } outputs; + + void operator()(int frames) + { + for(int c = 0; c < outputs.audio.channels; c++) + for(int i = 0; i < frames; i++) + outputs.audio.samples[c][i] = inputs.audio.samples[c][i] * inputs.gain.value; + } + + // Message bus for Tier C: the window itself is the UI-side endpoint -- + // the bindings wire window.send_message and call window.process_message. + struct ui_to_processor + { + float value; + }; + struct processor_to_ui + { + float applied; + }; + + void process_message(const ui_to_processor& msg) + { + // Deliberately not the same as the direct edit (msg.value / 2) so the + // bus path is observable in tests. + inputs.gain.value = msg.value * 0.5f; + if(send_message) + send_message(processor_to_ui{.applied = inputs.gain.value}); + } + std::function send_message; + +#if defined(_WIN32) + struct ui + { + struct window + { + static constexpr int width = 360, height = 120; + static constexpr int gain_param = 0; // flat index in parameter order + + // Bindings pass the model when the constructor accepts it + // (avnd::make_ui_editor); in CLAP/VST2 this is the live processor + // instance, in VST3 the controller-side model. + explicit window(avnd::effect_container& fx) + : fx{fx} + { + } + + ~window() { close(); } + + void open(avnd::gui_parent parent, avnd::gui_host h) + { + host = h; + + WNDCLASSW wc{}; + wc.lpfnWndProc = &window::wndproc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.lpszClassName = L"avnd_custom_ui_window_example"; + RegisterClassW(&wc); // idempotent: re-registration just fails + + hwnd = CreateWindowExW( + 0, wc.lpszClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, width, height, + (HWND)parent.handle, nullptr, wc.hInstance, this); + + // Repaint tick, dispatched by the host's message pump: host + // automation moves the bar without any callback plumbing. + SetTimer(hwnd, 1, 33, nullptr); + } + + void close() + { + if(hwnd) + { + KillTimer(hwnd, 1); + DestroyWindow(hwnd); + hwnd = nullptr; + } + } + + // Host idle tick (effEditIdle, clap.timer-support): nothing to do, + // WM_TIMER already drives us through the message pump. + void idle() { } + + std::pair size() const { return {width, height}; } + + // ---- Message bus endpoints (wired by the bindings) ---- + // UI thread -> processor; called on mouse release below. + std::function send_message; + // processor -> UI thread + void process_message(processor_to_ui msg) { feedback = msg.applied; } + float feedback{-1.f}; + + private: + float gain() const { return fx.effect.inputs.gain.value; } + + void set_gain_from(int x) + { + const float v = std::clamp(float(x) / float(width), 0.f, 1.f); + // Write the model first, then tell the host: the bindings sample + // the current value when broadcasting the automation point. + fx.effect.inputs.gain.value = v; + host.perform_edit(host.ctx, gain_param, v); + } + + void paint(HDC dc) + { + RECT rc{0, 0, width, height}; + const HBRUSH bg = CreateSolidBrush(RGB(30, 30, 34)); + FillRect(dc, &rc, bg); + DeleteObject(bg); + + RECT bar{16, 32, 16 + (int)((width - 32) * gain()), height - 32}; + const HBRUSH fill = CreateSolidBrush(RGB(255, 176, 30)); + FillRect(dc, &bar, fill); + DeleteObject(fill); + + char label[64]; + std::snprintf(label, sizeof(label), "gain: %.2f", gain()); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, RGB(220, 220, 220)); + TextOutA(dc, 16, 8, label, (int)strlen(label)); + } + + static LRESULT CALLBACK wndproc(HWND h, UINT msg, WPARAM wp, LPARAM lp) + { + if(msg == WM_NCCREATE) + { + auto* cs = (CREATESTRUCTW*)lp; + SetWindowLongPtrW(h, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams); + return DefWindowProcW(h, msg, wp, lp); + } + auto* self = (window*)GetWindowLongPtrW(h, GWLP_USERDATA); + if(!self) + return DefWindowProcW(h, msg, wp, lp); + + switch(msg) + { + case WM_LBUTTONDOWN: + SetCapture(h); + self->dragging = true; + self->host.begin_edit(self->host.ctx, gain_param); + self->set_gain_from((int)(short)LOWORD(lp)); + InvalidateRect(h, nullptr, FALSE); + return 0; + + case WM_MOUSEMOVE: + if(self->dragging) + { + self->set_gain_from((int)(short)LOWORD(lp)); + InvalidateRect(h, nullptr, FALSE); + } + return 0; + + case WM_LBUTTONUP: + if(self->dragging) + { + self->dragging = false; + ReleaseCapture(); + self->host.end_edit(self->host.ctx, gain_param); + if(self->send_message) + self->send_message(ui_to_processor{.value = self->gain()}); + } + return 0; + + case WM_TIMER: + InvalidateRect(h, nullptr, FALSE); + return 0; + + case WM_PAINT: { + PAINTSTRUCT ps; + const HDC dc = BeginPaint(h, &ps); + self->paint(dc); + EndPaint(h, &ps); + return 0; + } + + default: + return DefWindowProcW(h, msg, wp, lp); + } + } + + avnd::effect_container& fx; + avnd::gui_host host{}; + HWND hwnd{}; + bool dragging{}; + }; + }; +#elif defined(__linux__) && __has_include() + struct ui + { + struct window + { + static constexpr int width = 360, height = 120; + static constexpr int gain_param = 0; // flat index in parameter order + + explicit window(avnd::effect_container& fx) + : fx{fx} + { + } + + ~window() { close(); } + + void open(avnd::gui_parent parent, avnd::gui_host h) + { + host = h; + + // The author's framework owns its display connection, like a + // toolkit would; the host only hands us the parent window id. + dpy = XOpenDisplay(nullptr); + if(!dpy) + return; + const int screen = DefaultScreen(dpy); + win = XCreateSimpleWindow( + dpy, (::Window)(uintptr_t)parent.handle, 0, 0, width, height, 0, + BlackPixel(dpy, screen), rgb(30, 30, 34)); + XSelectInput( + dpy, win, + ExposureMask | ButtonPressMask | ButtonReleaseMask + | PointerMotionMask); + gc = XCreateGC(dpy, win, 0, nullptr); + XMapWindow(dpy, win); + XFlush(dpy); + } + + void close() + { + if(dpy) + { + if(gc) + XFreeGC(dpy, gc); + if(win) + XDestroyWindow(dpy, win); + XCloseDisplay(dpy); + dpy = nullptr; + win = 0; + gc = nullptr; + } + } + + // Host idle tick (effEditIdle, clap.timer-support, VST3 IRunLoop): + // this is the author window's event loop *and* its repaint timer, so + // host automation shows up without any callback plumbing. + void idle() + { + if(!dpy) + return; + while(XPending(dpy)) + { + XEvent ev; + XNextEvent(dpy, &ev); + switch(ev.type) + { + case ButtonPress: + if(ev.xbutton.button == Button1) + { + dragging = true; + host.begin_edit(host.ctx, gain_param); + set_gain_from(ev.xbutton.x); + } + break; + case MotionNotify: + if(dragging) + set_gain_from(ev.xmotion.x); + break; + case ButtonRelease: + if(ev.xbutton.button == Button1 && dragging) + { + dragging = false; + host.end_edit(host.ctx, gain_param); + if(send_message) + send_message(ui_to_processor{.value = gain()}); + } + break; + default: + break; + } + } + paint(); + } + + std::pair size() const { return {width, height}; } + + // ---- Message bus endpoints (wired by the bindings) ---- + // UI thread -> processor; called on mouse release above. + std::function send_message; + // processor -> UI thread + void process_message(processor_to_ui msg) { feedback = msg.applied; } + float feedback{-1.f}; + + private: + static unsigned long rgb(int r, int g, int b) + { + // Direct pixel for the ubiquitous 24-bit TrueColor visual: an + // example is allowed this shortcut, a real toolkit would XAllocColor. + return ((unsigned long)r << 16) | ((unsigned long)g << 8) + | (unsigned long)b; + } + + float gain() const { return fx.effect.inputs.gain.value; } + + void set_gain_from(int x) + { + const float v = std::clamp(float(x) / float(width), 0.f, 1.f); + // Write the model first, then tell the host: the bindings sample + // the current value when broadcasting the automation point. + fx.effect.inputs.gain.value = v; + host.perform_edit(host.ctx, gain_param, v); + } + + void paint() + { + XSetForeground(dpy, gc, rgb(30, 30, 34)); + XFillRectangle(dpy, win, gc, 0, 0, width, height); + + XSetForeground(dpy, gc, rgb(255, 176, 30)); + XFillRectangle( + dpy, win, gc, 16, 32, (unsigned)((width - 32) * gain()), + height - 64); + + char label[64]; + std::snprintf(label, sizeof(label), "gain: %.2f", gain()); + XSetForeground(dpy, gc, rgb(220, 220, 220)); + XDrawString(dpy, win, gc, 16, 20, label, (int)strlen(label)); + XFlush(dpy); + } + + avnd::effect_container& fx; + avnd::gui_host host{}; + Display* dpy{}; + ::Window win{}; + GC gc{}; + bool dragging{}; + }; + }; +#endif +}; +} diff --git a/tests/ui/gui_test_host.hpp b/tests/ui/gui_test_host.hpp new file mode 100644 index 00000000..b4d6a42f --- /dev/null +++ b/tests/ui/gui_test_host.hpp @@ -0,0 +1,380 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +/** + * Shared platform layer for the end-to-end editor tests: a real parent + * window, a message/event pump with a per-tick host callback, child-window + * discovery, synthetic mouse input aimed at the embedded editor, and a + * best-effort PPM capture of what the editor blitted. + * + * Win32: the pump dispatches the thread queue (pugl's SetTimer path); input + * is posted as WM_* messages to the child HWND. + * X11: the test host owns its own Display connection; the plug-in's editor + * runs on pugl's separate connection, driven by the per-tick callback (the + * host timer the bindings register: clap.timer-support / effEditIdle / + * VST3 IRunLoop). Input is delivered with XSendEvent to the child window — + * pugl translates synthetic core events like real ones. + */ + +#include + +#if defined(_WIN32) + +#if !defined(WIN32_LEAN_AND_MEAN) +#define WIN32_LEAN_AND_MEAN 1 +#endif +#if !defined(NOMINMAX) +#define NOMINMAX 1 +#endif +#include +// windows.h macro hygiene: these collide with enumerator names in binding +// headers included after this file (e.g. vintage.hpp's key enums). +#undef DELETE +#undef ALTERNATE +#undef WINDING + +#include +#include + +namespace avnd_test_gui +{ +using native_window = HWND; + +inline void pump_messages(int ms, const std::function& tick = {}) +{ + const DWORD end = GetTickCount() + ms; + for(;;) + { + MSG msg; + while(PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if(tick) + tick(); + if(GetTickCount() >= end) + break; + Sleep(5); + } +} + +inline native_window create_parent(int w, int h, const wchar_t* class_name) +{ + WNDCLASSW wc{}; + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = class_name; + RegisterClassW(&wc); + RECT r{0, 0, w, h}; + AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE); + return CreateWindowExW( + 0, wc.lpszClassName, L"avnd gui test", WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, CW_USEDEFAULT, r.right - r.left, r.bottom - r.top, nullptr, + nullptr, wc.hInstance, nullptr); +} + +inline void destroy_parent(native_window w) +{ + DestroyWindow(w); +} + +namespace detail +{ +struct child_finder +{ + HWND child{}; + static BOOL CALLBACK cb(HWND hwnd, LPARAM p) + { + ((child_finder*)p)->child = hwnd; + return FALSE; + } +}; +} + +inline native_window find_child(native_window parent) +{ + detail::child_finder finder; + EnumChildWindows(parent, &detail::child_finder::cb, (LPARAM)&finder); + return finder.child; +} + +inline std::pair window_size(native_window w) +{ + RECT rc{}; + GetClientRect(w, &rc); + return {(int)rc.right, (int)rc.bottom}; +} + +inline void mouse_move(native_window w, int x, int y, bool button_held) +{ + PostMessageW(w, WM_MOUSEMOVE, button_held ? MK_LBUTTON : 0, MAKELPARAM(x, y)); +} + +inline void mouse_press(native_window w, int x, int y) +{ + PostMessageW(w, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(x, y)); +} + +inline void mouse_release(native_window w, int x, int y) +{ + PostMessageW(w, WM_LBUTTONUP, 0, MAKELPARAM(x, y)); +} + +// Best-effort visual artifact: capture the editor window into a PPM. +inline void capture_window(native_window hwnd, const char* path) +{ + RECT rc{}; + GetClientRect(hwnd, &rc); + const int w = rc.right, h = rc.bottom; + if(w <= 0 || h <= 0) + return; + + const HDC win_dc = GetDC(hwnd); + const HDC mem_dc = CreateCompatibleDC(win_dc); + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = w; + bmi.bmiHeader.biHeight = -h; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + void* bits{}; + if(const HBITMAP bmp + = CreateDIBSection(win_dc, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0)) + { + const auto old = SelectObject(mem_dc, bmp); + // PW_RENDERFULLCONTENT (2): ask DWM for the actual content + if(!PrintWindow(hwnd, mem_dc, 2)) + BitBlt(mem_dc, 0, 0, w, h, win_dc, 0, 0, SRCCOPY); + if(std::FILE* f = std::fopen(path, "wb")) + { + std::fprintf(f, "P6\n%d %d\n255\n", w, h); + auto* px = (const unsigned char*)bits; + for(int i = 0; i < w * h; i++) + { + const unsigned char rgb[3] = {px[i * 4 + 2], px[i * 4 + 1], px[i * 4 + 0]}; + std::fwrite(rgb, 1, 3, f); + } + std::fclose(f); + } + SelectObject(mem_dc, old); + DeleteObject(bmp); + } + DeleteDC(mem_dc); + ReleaseDC(hwnd, win_dc); +} +} + +#elif defined(__linux__) || defined(__FreeBSD__) + +#include +#include + +#include +#include +#include + +namespace avnd_test_gui +{ +using native_window = ::Window; + +namespace detail +{ +// One connection for the whole test binary; the plug-in editor talks to the +// server through pugl's own connection. +inline Display* dpy() +{ + static Display* d = XOpenDisplay(nullptr); + return d; +} +} + +// The editor makes progress through the per-tick host callback (clap timer, +// effEditIdle, IRunLoop timer, ...): pugl only processes its connection +// inside editor->idle(). The test host itself has nothing to dispatch, but +// drain our connection anyway so the server never blocks on us. +inline void pump_messages(int ms, const std::function& tick = {}) +{ + auto* d = detail::dpy(); + const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); + for(;;) + { + if(d) + { + while(XPending(d)) + { + XEvent ev; + XNextEvent(d, &ev); + } + } + if(tick) + tick(); + if(std::chrono::steady_clock::now() >= end) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} + +inline native_window create_parent(int w, int h, const wchar_t* /* class_name */) +{ + auto* d = detail::dpy(); + if(!d) + return 0; + const int screen = DefaultScreen(d); + const ::Window win = XCreateSimpleWindow( + d, RootWindow(d, screen), 0, 0, (unsigned)w, (unsigned)h, 0, + BlackPixel(d, screen), BlackPixel(d, screen)); + XStoreName(d, win, "avnd gui test"); + XSelectInput(d, win, StructureNotifyMask); + XMapWindow(d, win); + // Wait for the map so the plug-in can parent into a viewable window. + for(int i = 0; i < 200; i++) + { + XWindowAttributes attrs{}; + XGetWindowAttributes(d, win, &attrs); + if(attrs.map_state == IsViewable) + break; + pump_messages(5); + } + XSync(d, False); + return win; +} + +inline void destroy_parent(native_window w) +{ + if(auto* d = detail::dpy(); d && w) + { + XDestroyWindow(d, w); + XSync(d, False); + } +} + +inline native_window find_child(native_window parent) +{ + auto* d = detail::dpy(); + if(!d || !parent) + return 0; + XSync(d, False); + ::Window root{}, par{}; + ::Window* children{}; + unsigned int n{}; + ::Window found{}; + if(XQueryTree(d, parent, &root, &par, &children, &n) && n > 0) + found = children[0]; + if(children) + XFree(children); + return found; +} + +inline std::pair window_size(native_window w) +{ + auto* d = detail::dpy(); + if(!d || !w) + return {0, 0}; + XWindowAttributes attrs{}; + XGetWindowAttributes(d, w, &attrs); + return {attrs.width, attrs.height}; +} + +namespace detail +{ +inline XEvent +make_pointer_event(native_window w, int type, int x, int y, bool button_held) +{ + auto* d = dpy(); + XEvent ev{}; + ev.xany.display = d; + ev.xany.window = w; + if(type == MotionNotify) + { + ev.type = MotionNotify; + ev.xmotion.window = w; + ev.xmotion.root = DefaultRootWindow(d); + ev.xmotion.x = x; + ev.xmotion.y = y; + ev.xmotion.state = button_held ? Button1Mask : 0; + ev.xmotion.time = CurrentTime; + ev.xmotion.same_screen = True; + } + else + { + ev.type = type; + ev.xbutton.window = w; + ev.xbutton.root = DefaultRootWindow(d); + ev.xbutton.x = x; + ev.xbutton.y = y; + ev.xbutton.button = Button1; + ev.xbutton.state = type == ButtonRelease ? Button1Mask : 0; + ev.xbutton.time = CurrentTime; + ev.xbutton.same_screen = True; + } + return ev; +} + +inline void send_event(native_window w, XEvent ev, long mask) +{ + auto* d = dpy(); + XSendEvent(d, w, True, mask, &ev); + XFlush(d); +} +} + +inline void mouse_move(native_window w, int x, int y, bool button_held) +{ + detail::send_event( + w, detail::make_pointer_event(w, MotionNotify, x, y, button_held), + PointerMotionMask); +} + +inline void mouse_press(native_window w, int x, int y) +{ + detail::send_event( + w, detail::make_pointer_event(w, ButtonPress, x, y, false), ButtonPressMask); +} + +inline void mouse_release(native_window w, int x, int y) +{ + detail::send_event( + w, detail::make_pointer_event(w, ButtonRelease, x, y, true), + ButtonReleaseMask); +} + +// Best-effort visual artifact: capture the editor window into a PPM. +inline void capture_window(native_window w, const char* path) +{ + auto* d = detail::dpy(); + if(!d || !w) + return; + XSync(d, False); + XWindowAttributes attrs{}; + XGetWindowAttributes(d, w, &attrs); + if(attrs.width <= 0 || attrs.height <= 0 || attrs.map_state != IsViewable) + return; + XImage* img + = XGetImage(d, w, 0, 0, attrs.width, attrs.height, AllPlanes, ZPixmap); + if(!img) + return; + if(std::FILE* f = std::fopen(path, "wb")) + { + std::fprintf(f, "P6\n%d %d\n255\n", attrs.width, attrs.height); + for(int y = 0; y < attrs.height; y++) + { + for(int x = 0; x < attrs.width; x++) + { + const unsigned long px = XGetPixel(img, x, y); + const unsigned char rgb[3] + = {(unsigned char)((px & img->red_mask) >> 16), + (unsigned char)((px & img->green_mask) >> 8), + (unsigned char)(px & img->blue_mask)}; + std::fwrite(rgb, 1, 3, f); + } + } + std::fclose(f); + } + XDestroyImage(img); +} +} + +#endif diff --git a/tests/ui/test_custom_ui_window.cpp b/tests/ui/test_custom_ui_window.cpp new file mode 100644 index 00000000..ebec8ccd --- /dev/null +++ b/tests/ui/test_custom_ui_window.cpp @@ -0,0 +1,252 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +// Tier C regression test: the CustomUiWindow example ships its own raw +// editor through the avnd::gui_windowed_ui seam (no soft editor, no +// Nuklear) — raw Win32 + GDI on Windows, raw Xlib on Linux. Load its .clap, +// embed the editor, drag, and verify the gesture flow — proving a +// bring-your-own-framework UI gets the same treatment as the reference +// editor. + +#include + +#if (defined(_WIN32) || defined(__linux__)) && defined(AVND_TEST_CUSTOM_UI_CLAP_PATH) + +#include "gui_test_host.hpp" + +#include + +#if defined(_WIN32) +#define AVND_TEST_DLOPEN(path) (void*)LoadLibraryA(path) +#define AVND_TEST_DLSYM(lib, name) (void*)GetProcAddress((HMODULE)lib, name) +#define AVND_TEST_DLCLOSE(lib) FreeLibrary((HMODULE)lib) +#else +#include +#define AVND_TEST_DLOPEN(path) dlopen(path, RTLD_NOW | RTLD_LOCAL) +#define AVND_TEST_DLSYM(lib, name) dlsym(lib, name) +#define AVND_TEST_DLCLOSE(lib) dlclose(lib) +#endif + +#include +#include +#include + +namespace +{ +struct out_event_collector +{ + clap_output_events list{}; + std::vector> events; + + out_event_collector() + { + list.ctx = this; + list.try_push + = [](const clap_output_events* list, const clap_event_header* event) -> bool { + auto& self = *(out_event_collector*)list->ctx; + auto* bytes = (const char*)event; + self.events.emplace_back(bytes, bytes + event->size); + return true; + }; + } + + int count(uint16_t type) const + { + int n = 0; + for(auto& e : events) + n += ((const clap_event_header*)e.data())->type == type; + return n; + } +}; + +static const clap_input_events empty_in_events = { + .ctx = nullptr, + .size = [](const clap_input_events*) -> uint32_t { return 0; }, + .get = [](const clap_input_events*, + uint32_t) -> const clap_event_header* { return nullptr; }}; + +// Host with clap.timer-support: Tier C editors are promised their idle() +// clock through it (that's their whole event loop on X11). +struct test_host +{ + clap_host host{}; + clap_host_timer_support timer_ext{}; + std::vector timers; + + test_host() + { + host.clap_version = CLAP_VERSION; + host.host_data = this; + host.name = "avnd-test-host"; + host.vendor = "avendish"; + host.url = ""; + host.version = "1.0"; + host.get_extension = [](const clap_host* h, const char* id) -> const void* { + auto& self = *(test_host*)h->host_data; + if(std::strcmp(id, CLAP_EXT_TIMER_SUPPORT) == 0) + return &self.timer_ext; + return nullptr; + }; + host.request_restart = [](const clap_host*) {}; + host.request_process = [](const clap_host*) {}; + host.request_callback = [](const clap_host*) {}; + + timer_ext.register_timer + = [](const clap_host* h, uint32_t, clap_id* id) -> bool { + auto& self = *(test_host*)h->host_data; + *id = (clap_id)(1000 + self.timers.size()); + self.timers.push_back(*id); + return true; + }; + timer_ext.unregister_timer = [](const clap_host* h, clap_id id) -> bool { + auto& self = *(test_host*)h->host_data; + std::erase(self.timers, id); + return true; + }; + } +}; +} + +TEST_CASE("tier C: author-provided editor through the plug-in bindings", "[custom_ui]") +{ + using namespace avnd_test_gui; + + void* lib = AVND_TEST_DLOPEN(AVND_TEST_CUSTOM_UI_CLAP_PATH); + REQUIRE(lib); + const auto* entry = (const clap_plugin_entry*)AVND_TEST_DLSYM(lib, "clap_entry"); + REQUIRE(entry); + REQUIRE(entry->init(AVND_TEST_CUSTOM_UI_CLAP_PATH)); + + const auto* factory + = (const clap_plugin_factory*)entry->get_factory(CLAP_PLUGIN_FACTORY_ID); + REQUIRE(factory); + const auto* desc = factory->get_plugin_descriptor(factory, 0); + REQUIRE(desc); + + test_host host; + const auto* plugin = factory->create_plugin(factory, &host.host, desc->id); + REQUIRE(plugin); + REQUIRE(plugin->init(plugin)); + + const auto* gui = (const clap_plugin_gui*)plugin->get_extension(plugin, CLAP_EXT_GUI); + REQUIRE(gui); + const auto* params + = (const clap_plugin_params*)plugin->get_extension(plugin, CLAP_EXT_PARAMS); + REQUIRE(params); + const auto* timer_support = (const clap_plugin_timer_support*)plugin->get_extension( + plugin, CLAP_EXT_TIMER_SUPPORT); + REQUIRE(timer_support); + + const auto tick = [&] { + for(const clap_id id : host.timers) + timer_support->on_timer(plugin, id); + }; + +#if defined(_WIN32) + REQUIRE(gui->create(plugin, CLAP_WINDOW_API_WIN32, false)); +#else + REQUIRE(gui->create(plugin, CLAP_WINDOW_API_X11, false)); +#endif + uint32_t w{}, h{}; + REQUIRE(gui->get_size(plugin, &w, &h)); + CHECK(w == 360); // the author's window::size(), not a soft-editor layout + CHECK(h == 120); + + const native_window parent = create_parent(w, h, L"avnd_custom_ui_test_parent"); + REQUIRE(parent); + clap_window cw{}; +#if defined(_WIN32) + cw.api = CLAP_WINDOW_API_WIN32; + cw.win32 = parent; +#else + cw.api = CLAP_WINDOW_API_X11; + cw.x11 = parent; +#endif + REQUIRE(gui->set_parent(plugin, &cw)); + + pump_messages(200, tick); + +#if defined(_WIN32) + // The author's window class, not pugl's + const HWND child + = FindWindowExW(parent, nullptr, L"avnd_custom_ui_window_example", nullptr); +#else + const native_window child = find_child(parent); +#endif + REQUIRE(child); + + double before{}; + REQUIRE(params->get_value(plugin, 0, &before)); + CHECK(before == 0.5); + + // Drag the gain bar to ~75% width + mouse_press(child, 180, 60); + pump_messages(100, tick); + mouse_move(child, 270, 60, true); + pump_messages(100, tick); + mouse_release(child, 270, 60); + pump_messages(100, tick); + + double after{}; + REQUIRE(params->get_value(plugin, 0, &after)); + CHECK(after > 0.7); + + // The same gesture queue as the soft editor serves author editors + out_event_collector out; + params->flush(plugin, &empty_in_events, &out.list); + CHECK(out.count(CLAP_EVENT_PARAM_GESTURE_BEGIN) == 1); + CHECK(out.count(CLAP_EVENT_PARAM_GESTURE_END) == 1); + CHECK(out.count(CLAP_EVENT_PARAM_VALUE) >= 1); + + // Tier C message bus: the release sent ui_to_processor{gain} through the + // window's send_message; process() drains it and the processor sets + // gain = value/2 (deliberately different from the direct edit). + REQUIRE(plugin->activate(plugin, 48000., 64, 64)); + REQUIRE(plugin->start_processing(plugin)); + + float in_l[64]{}, in_r[64]{}, out_l[64]{}, out_r[64]{}; + float* ins[2] = {in_l, in_r}; + float* outs[2] = {out_l, out_r}; + clap_audio_buffer in_bus{}; + in_bus.data32 = ins; + in_bus.channel_count = 2; + clap_audio_buffer out_bus{}; + out_bus.data32 = outs; + out_bus.channel_count = 2; + + out_event_collector process_out; + clap_process proc{}; + proc.frames_count = 64; + proc.audio_inputs = &in_bus; + proc.audio_inputs_count = 1; + proc.audio_outputs = &out_bus; + proc.audio_outputs_count = 1; + proc.in_events = &empty_in_events; + proc.out_events = &process_out.list; + REQUIRE(plugin->process(plugin, &proc) != CLAP_PROCESS_ERROR); + + double gain_after_bus{}; + REQUIRE(params->get_value(plugin, 0, &gain_after_bus)); + CHECK(std::abs(gain_after_bus - after * 0.5) < 1e-6); + + // The feedback message drains into window.process_message on the timer. + pump_messages(150, tick); + + plugin->stop_processing(plugin); + plugin->deactivate(plugin); + + gui->destroy(plugin); + pump_messages(50); + destroy_parent(parent); + plugin->destroy(plugin); + entry->deinit(); + AVND_TEST_DLCLOSE(lib); +} + +#else + +TEST_CASE("tier C: author-provided editor through the plug-in bindings", "[custom_ui]") +{ + SKIP("custom ui window test needs Win32 or X11"); +} + +#endif From 02e4ebc9d3a9d14e592522ee5a1170415cc3019b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 6 Jul 2026 18:12:25 -0400 Subject: [PATCH 7/8] ci: X11 headers + xvfb on the Ubuntu lanes so the editor tests run The end-to-end editor tests embed real windows; without X11 dev headers they are skipped at configure time, and without a display they cannot run. Install libx11/xrandr/xext/xcursor + xvfb and run the test steps under xvfb-run on Linux. Co-Authored-By: Claude Fable 5 --- .github/workflows/build_cmake.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_cmake.yml b/.github/workflows/build_cmake.yml index 7e612311..38e18625 100644 --- a/.github/workflows/build_cmake.yml +++ b/.github/workflows/build_cmake.yml @@ -45,7 +45,8 @@ jobs: chmod +x ./llvm.sh ; \ sudo ./llvm.sh 22 ; \ sudo apt update ; \ - sudo apt install ninja-build clang-22 lld-22 libc++-22-dev libc++abi-22-dev", + sudo apt install ninja-build clang-22 lld-22 libc++-22-dev libc++abi-22-dev \ + libx11-dev libxrandr-dev libxext-dev libxcursor-dev xvfb", sdk: "/opt/ossia-sdk-x86_64", pre_build: "" } @@ -61,7 +62,8 @@ jobs: sudo apt install lsb-release wget software-properties-common ; \ sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test ; \ sudo apt update ; \ - sudo apt install ninja-build gcc-16 g++-16 libgstreamer1.0-dev", + sudo apt install ninja-build gcc-16 g++-16 libgstreamer1.0-dev \ + libx11-dev libxrandr-dev libxext-dev libxcursor-dev xvfb", sdk: "/opt/ossia-sdk-x86_64", pre_build: "" } @@ -164,7 +166,12 @@ jobs: export PATH="${{ matrix.config.path }}:$PATH" fi - cmake --build . --target test + # The editor tests open real windows: give them an X server. + if [[ "$RUNNER_OS" == "Linux" ]]; then + xvfb-run -a -s "-screen 0 1280x960x24" cmake --build . --target test + else + cmake --build . --target test + fi - name: Build release shell: bash @@ -205,4 +212,9 @@ jobs: export PATH="${{ matrix.config.path }}:$PATH" fi - cmake --build . --target test + # The editor tests open real windows: give them an X server. + if [[ "$RUNNER_OS" == "Linux" ]]; then + xvfb-run -a -s "-screen 0 1280x960x24" cmake --build . --target test + else + cmake --build . --target test + fi From 1452d426f33dbbc4bea4db6d17b2695444b747ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 8 Jul 2026 22:46:36 -0400 Subject: [PATCH 8/8] examples: split the Tier C editor's Win32/X11 code into per-OS TUs CustomUiWindow.hpp carried both platform implementations of its author window inline, guarded by a #if/#elif that made the file two editors in a trenchcoat and dragged / (plus their macro-hygiene #undefs) into the middle of every plug-in TU that includes it. Hide the window behind a forward-declared pimpl: the header now declares only the avnd::gui_windowed_ui contract (open/close/idle/size) plus the message-bus endpoints the bindings introspect, and holds a std::unique_ptr. The two implementations move to sibling translation units, CustomUiWindow.win32.cpp (raw Win32 + GDI) and CustomUiWindow.x11.cpp (raw Xlib), each compiled into the plug-in modules by examples.cmake for its platform (and linking X11 there, as before). The header pulls in no windowing header at all -- exactly how a real toolkit editor would hide behind its own opaque handle. Behaviour is unchanged: the window class name, 360x120 size, gain gesture flow and message bus are identical. test_custom_ui_window still passes (clap module, live X display); vst3/vintage editor modules still build. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmake/avendish.examples.cmake | 30 +- examples/Advanced/UI/CustomUiWindow.hpp | 352 ++---------------- examples/Advanced/UI/CustomUiWindow.win32.cpp | 192 ++++++++++ examples/Advanced/UI/CustomUiWindow.x11.cpp | 178 +++++++++ 4 files changed, 420 insertions(+), 332 deletions(-) create mode 100644 examples/Advanced/UI/CustomUiWindow.win32.cpp create mode 100644 examples/Advanced/UI/CustomUiWindow.x11.cpp diff --git a/cmake/avendish.examples.cmake b/cmake/avendish.examples.cmake index f448ae78..e2a9335f 100644 --- a/cmake/avendish.examples.cmake +++ b/cmake/avendish.examples.cmake @@ -162,18 +162,32 @@ avnd_make_audioplug( MAIN_CLASS examples::CustomUiWindow C_NAME avnd_custom_ui_window ) -# The example's Xlib editor must be linked into the plug-in modules -# themselves: a host has no reason to have libX11 loaded when it dlopens -# them. (Without X11 headers the example simply has no editor.) -if(UNIX AND NOT APPLE) +# The editor's two platform implementations live in their own translation +# units (behind the pimpl in CustomUiWindow.hpp). Compile the matching one into +# each plug-in module, and link its windowing library: a host has no reason to +# have libX11 loaded when it dlopens the module. Without an implementation for +# the platform (or, on Linux, without the X11 headers) the example simply has +# no editor, matching the header's own guard. +set(_avnd_customui_impl "") +set(_avnd_customui_x11 0) +if(WIN32) + set(_avnd_customui_impl "${CMAKE_CURRENT_SOURCE_DIR}/examples/Advanced/UI/CustomUiWindow.win32.cpp") +elseif(UNIX AND NOT APPLE) find_package(X11 QUIET) if(X11_FOUND) - foreach(_avnd_backend clap vst3 vintage) - if(TARGET CustomUiWindow_${_avnd_backend}) + set(_avnd_customui_impl "${CMAKE_CURRENT_SOURCE_DIR}/examples/Advanced/UI/CustomUiWindow.x11.cpp") + set(_avnd_customui_x11 1) + endif() +endif() +if(_avnd_customui_impl) + foreach(_avnd_backend clap vst3 vintage) + if(TARGET CustomUiWindow_${_avnd_backend}) + target_sources(CustomUiWindow_${_avnd_backend} PRIVATE "${_avnd_customui_impl}") + if(_avnd_customui_x11) target_link_libraries(CustomUiWindow_${_avnd_backend} PRIVATE X11::X11) endif() - endforeach() - endif() + endif() + endforeach() endif() # GCC segfaults with those two... diff --git a/examples/Advanced/UI/CustomUiWindow.hpp b/examples/Advanced/UI/CustomUiWindow.hpp index 773bff63..71fae7ce 100644 --- a/examples/Advanced/UI/CustomUiWindow.hpp +++ b/examples/Advanced/UI/CustomUiWindow.hpp @@ -17,8 +17,9 @@ * }; * * Any UI stack works behind this seam -- Dear ImGui, Qt, JUCE, ... This - * example deliberately uses no library at all: a raw Win32 child window - * painted with GDI, so the contract itself is the whole story: + * example deliberately uses no library at all: two hand-rolled implementations + * of the same contract, a raw Win32 child window painted with GDI and a raw + * Xlib window. So the contract itself is the whole story: * * - open() receives the host's parent window handle and creates whatever * the framework needs inside it; @@ -30,13 +31,14 @@ * the bindings translate to CLAP gesture events / audioMasterAutomate / * IComponentHandler; * - ticks come from the host (clap.timer-support, effEditIdle) and from - * the host's message pump for frameworks with their own timers, like - * the SetTimer used here. + * the host's message pump for frameworks with their own timers. * - * Two hand-rolled implementations of the same contract: raw Win32 + GDI on - * Windows, raw Xlib on Linux (when the X11 headers are present). On other - * platforms the plug-in simply has no editor (the concept is not satisfied, - * bindings skip it). + * The two platform implementations live in the sibling translation units + * CustomUiWindow.win32.cpp and CustomUiWindow.x11.cpp, reached through a + * forward-declared pimpl so this header pulls in no windowing headers at all + * (a real toolkit would hide the same way behind its own opaque handle). On + * platforms with neither backend the plug-in simply has no editor: the + * concept is not satisfied and the bindings skip it. */ #include @@ -45,40 +47,8 @@ #include #include -#if defined(_WIN32) -#if !defined(WIN32_LEAN_AND_MEAN) -#define WIN32_LEAN_AND_MEAN 1 -#endif -#if !defined(NOMINMAX) -#define NOMINMAX 1 -#endif -#include -// windows.h macro hygiene: these collide with enumerator names in binding -// headers included after this file (e.g. vintage.hpp's key enums). -#undef DELETE -#undef ALTERNATE -#undef WINDING -#elif defined(__linux__) && __has_include() -// Pull Xutil.h too (it needs the Bool/Status macros): later headers in the -// same TU that want it (the soft UI blit) will then no-op on their include. -#include -#include -// Xlib macro hygiene: this header is included in the middle of plug-in TUs, -// so drop the macros that collide with everything (keep the event type and -// mask constants, they are namespaced enough in practice). -#undef None -#undef Bool -#undef True -#undef False -#undef Status -#undef Always -#undef Success -#endif - -#include -#include -#include #include +#include #include namespace examples @@ -89,7 +59,7 @@ struct CustomUiWindow halp_meta(c_name, "avnd_custom_ui_window") halp_meta(category, "Demo") halp_meta(author, "Avendish") - halp_meta(description, "Gain with a hand-rolled (raw Win32) editor") + halp_meta(description, "Gain with a hand-rolled (raw Win32 / raw Xlib) editor") halp_meta(uuid, "aef14f10-fb50-45e9-9dc6-c40c5e5fe790") struct ins @@ -131,7 +101,10 @@ struct CustomUiWindow } std::function send_message; -#if defined(_WIN32) +// The editor exists only where a windowing implementation backs it: raw Win32 +// on Windows, raw Xlib on Linux when the X11 headers are present. Both are +// defined out-of-line in CustomUiWindow.{win32,x11}.cpp behind the pimpl below. +#if defined(_WIN32) || (defined(__linux__) && __has_include()) struct ui { struct window @@ -139,299 +112,30 @@ struct CustomUiWindow static constexpr int width = 360, height = 120; static constexpr int gain_param = 0; // flat index in parameter order - // Bindings pass the model when the constructor accepts it + // The bindings pass the model when the constructor accepts it // (avnd::make_ui_editor); in CLAP/VST2 this is the live processor // instance, in VST3 the controller-side model. - explicit window(avnd::effect_container& fx) - : fx{fx} - { - } - - ~window() { close(); } - - void open(avnd::gui_parent parent, avnd::gui_host h) - { - host = h; - - WNDCLASSW wc{}; - wc.lpfnWndProc = &window::wndproc; - wc.hInstance = GetModuleHandleW(nullptr); - wc.hCursor = LoadCursor(nullptr, IDC_ARROW); - wc.lpszClassName = L"avnd_custom_ui_window_example"; - RegisterClassW(&wc); // idempotent: re-registration just fails - - hwnd = CreateWindowExW( - 0, wc.lpszClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, width, height, - (HWND)parent.handle, nullptr, wc.hInstance, this); - - // Repaint tick, dispatched by the host's message pump: host - // automation moves the bar without any callback plumbing. - SetTimer(hwnd, 1, 33, nullptr); - } - - void close() - { - if(hwnd) - { - KillTimer(hwnd, 1); - DestroyWindow(hwnd); - hwnd = nullptr; - } - } - - // Host idle tick (effEditIdle, clap.timer-support): nothing to do, - // WM_TIMER already drives us through the message pump. - void idle() { } + explicit window(avnd::effect_container& fx); + ~window(); + // avnd::gui_windowed_ui contract, forwarded to the platform impl. + void open(avnd::gui_parent parent, avnd::gui_host h); + void close(); + void idle(); std::pair size() const { return {width, height}; } // ---- Message bus endpoints (wired by the bindings) ---- - // UI thread -> processor; called on mouse release below. + // UI thread -> processor; the impl calls it on mouse release. std::function send_message; // processor -> UI thread void process_message(processor_to_ui msg) { feedback = msg.applied; } float feedback{-1.f}; private: - float gain() const { return fx.effect.inputs.gain.value; } - - void set_gain_from(int x) - { - const float v = std::clamp(float(x) / float(width), 0.f, 1.f); - // Write the model first, then tell the host: the bindings sample - // the current value when broadcasting the automation point. - fx.effect.inputs.gain.value = v; - host.perform_edit(host.ctx, gain_param, v); - } - - void paint(HDC dc) - { - RECT rc{0, 0, width, height}; - const HBRUSH bg = CreateSolidBrush(RGB(30, 30, 34)); - FillRect(dc, &rc, bg); - DeleteObject(bg); - - RECT bar{16, 32, 16 + (int)((width - 32) * gain()), height - 32}; - const HBRUSH fill = CreateSolidBrush(RGB(255, 176, 30)); - FillRect(dc, &bar, fill); - DeleteObject(fill); - - char label[64]; - std::snprintf(label, sizeof(label), "gain: %.2f", gain()); - SetBkMode(dc, TRANSPARENT); - SetTextColor(dc, RGB(220, 220, 220)); - TextOutA(dc, 16, 8, label, (int)strlen(label)); - } - - static LRESULT CALLBACK wndproc(HWND h, UINT msg, WPARAM wp, LPARAM lp) - { - if(msg == WM_NCCREATE) - { - auto* cs = (CREATESTRUCTW*)lp; - SetWindowLongPtrW(h, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams); - return DefWindowProcW(h, msg, wp, lp); - } - auto* self = (window*)GetWindowLongPtrW(h, GWLP_USERDATA); - if(!self) - return DefWindowProcW(h, msg, wp, lp); - - switch(msg) - { - case WM_LBUTTONDOWN: - SetCapture(h); - self->dragging = true; - self->host.begin_edit(self->host.ctx, gain_param); - self->set_gain_from((int)(short)LOWORD(lp)); - InvalidateRect(h, nullptr, FALSE); - return 0; - - case WM_MOUSEMOVE: - if(self->dragging) - { - self->set_gain_from((int)(short)LOWORD(lp)); - InvalidateRect(h, nullptr, FALSE); - } - return 0; - - case WM_LBUTTONUP: - if(self->dragging) - { - self->dragging = false; - ReleaseCapture(); - self->host.end_edit(self->host.ctx, gain_param); - if(self->send_message) - self->send_message(ui_to_processor{.value = self->gain()}); - } - return 0; - - case WM_TIMER: - InvalidateRect(h, nullptr, FALSE); - return 0; - - case WM_PAINT: { - PAINTSTRUCT ps; - const HDC dc = BeginPaint(h, &ps); - self->paint(dc); - EndPaint(h, &ps); - return 0; - } - - default: - return DefWindowProcW(h, msg, wp, lp); - } - } - - avnd::effect_container& fx; - avnd::gui_host host{}; - HWND hwnd{}; - bool dragging{}; - }; - }; -#elif defined(__linux__) && __has_include() - struct ui - { - struct window - { - static constexpr int width = 360, height = 120; - static constexpr int gain_param = 0; // flat index in parameter order - - explicit window(avnd::effect_container& fx) - : fx{fx} - { - } - - ~window() { close(); } - - void open(avnd::gui_parent parent, avnd::gui_host h) - { - host = h; - - // The author's framework owns its display connection, like a - // toolkit would; the host only hands us the parent window id. - dpy = XOpenDisplay(nullptr); - if(!dpy) - return; - const int screen = DefaultScreen(dpy); - win = XCreateSimpleWindow( - dpy, (::Window)(uintptr_t)parent.handle, 0, 0, width, height, 0, - BlackPixel(dpy, screen), rgb(30, 30, 34)); - XSelectInput( - dpy, win, - ExposureMask | ButtonPressMask | ButtonReleaseMask - | PointerMotionMask); - gc = XCreateGC(dpy, win, 0, nullptr); - XMapWindow(dpy, win); - XFlush(dpy); - } - - void close() - { - if(dpy) - { - if(gc) - XFreeGC(dpy, gc); - if(win) - XDestroyWindow(dpy, win); - XCloseDisplay(dpy); - dpy = nullptr; - win = 0; - gc = nullptr; - } - } - - // Host idle tick (effEditIdle, clap.timer-support, VST3 IRunLoop): - // this is the author window's event loop *and* its repaint timer, so - // host automation shows up without any callback plumbing. - void idle() - { - if(!dpy) - return; - while(XPending(dpy)) - { - XEvent ev; - XNextEvent(dpy, &ev); - switch(ev.type) - { - case ButtonPress: - if(ev.xbutton.button == Button1) - { - dragging = true; - host.begin_edit(host.ctx, gain_param); - set_gain_from(ev.xbutton.x); - } - break; - case MotionNotify: - if(dragging) - set_gain_from(ev.xmotion.x); - break; - case ButtonRelease: - if(ev.xbutton.button == Button1 && dragging) - { - dragging = false; - host.end_edit(host.ctx, gain_param); - if(send_message) - send_message(ui_to_processor{.value = gain()}); - } - break; - default: - break; - } - } - paint(); - } - - std::pair size() const { return {width, height}; } - - // ---- Message bus endpoints (wired by the bindings) ---- - // UI thread -> processor; called on mouse release above. - std::function send_message; - // processor -> UI thread - void process_message(processor_to_ui msg) { feedback = msg.applied; } - float feedback{-1.f}; - - private: - static unsigned long rgb(int r, int g, int b) - { - // Direct pixel for the ubiquitous 24-bit TrueColor visual: an - // example is allowed this shortcut, a real toolkit would XAllocColor. - return ((unsigned long)r << 16) | ((unsigned long)g << 8) - | (unsigned long)b; - } - - float gain() const { return fx.effect.inputs.gain.value; } - - void set_gain_from(int x) - { - const float v = std::clamp(float(x) / float(width), 0.f, 1.f); - // Write the model first, then tell the host: the bindings sample - // the current value when broadcasting the automation point. - fx.effect.inputs.gain.value = v; - host.perform_edit(host.ctx, gain_param, v); - } - - void paint() - { - XSetForeground(dpy, gc, rgb(30, 30, 34)); - XFillRectangle(dpy, win, gc, 0, 0, width, height); - - XSetForeground(dpy, gc, rgb(255, 176, 30)); - XFillRectangle( - dpy, win, gc, 16, 32, (unsigned)((width - 32) * gain()), - height - 64); - - char label[64]; - std::snprintf(label, sizeof(label), "gain: %.2f", gain()); - XSetForeground(dpy, gc, rgb(220, 220, 220)); - XDrawString(dpy, win, gc, 16, 20, label, (int)strlen(label)); - XFlush(dpy); - } - - avnd::effect_container& fx; - avnd::gui_host host{}; - Display* dpy{}; - ::Window win{}; - GC gc{}; - bool dragging{}; + // Platform window + its paint/event code, defined per-OS in the matching + // .cpp; forward-declared here so no windowing header reaches this file. + struct impl; + std::unique_ptr self; }; }; #endif diff --git a/examples/Advanced/UI/CustomUiWindow.win32.cpp b/examples/Advanced/UI/CustomUiWindow.win32.cpp new file mode 100644 index 00000000..960092f5 --- /dev/null +++ b/examples/Advanced/UI/CustomUiWindow.win32.cpp @@ -0,0 +1,192 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +// Tier C editor -- Windows implementation. A raw Win32 child window painted +// with GDI, living behind the pimpl forward-declared in CustomUiWindow.hpp. +// See that header for the contract; the Linux sibling is CustomUiWindow.x11.cpp. + +#include "CustomUiWindow.hpp" + +#if defined(_WIN32) + +#if !defined(WIN32_LEAN_AND_MEAN) +#define WIN32_LEAN_AND_MEAN 1 +#endif +#if !defined(NOMINMAX) +#define NOMINMAX 1 +#endif +#include + +#include +#include +#include + +namespace examples +{ +// The whole framework side of the window: state, painting and the wndproc. +// Reads the gain straight off the effect model and drives the host's +// automation-gesture hooks -- exactly as a real toolkit-based editor would. +struct CustomUiWindow::ui::window::impl +{ + explicit impl(window& owner, avnd::effect_container& fx) + : owner{owner} + , fx{fx} + { + } + + ~impl() { close(); } + + void open(avnd::gui_parent parent, avnd::gui_host h) + { + host = h; + + WNDCLASSW wc{}; + wc.lpfnWndProc = &impl::wndproc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.lpszClassName = L"avnd_custom_ui_window_example"; + RegisterClassW(&wc); // idempotent: re-registration just fails + + hwnd = CreateWindowExW( + 0, wc.lpszClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, window::width, + window::height, (HWND)parent.handle, nullptr, wc.hInstance, this); + + // Repaint tick, dispatched by the host's message pump: host automation + // moves the bar without any callback plumbing. + SetTimer(hwnd, 1, 33, nullptr); + } + + void close() + { + if(hwnd) + { + KillTimer(hwnd, 1); + DestroyWindow(hwnd); + hwnd = nullptr; + } + } + + // Host idle tick (effEditIdle, clap.timer-support): nothing to do, WM_TIMER + // already drives us through the message pump. + void idle() { } + +private: + float gain() const { return fx.effect.inputs.gain.value; } + + void set_gain_from(int x) + { + const float v = std::clamp(float(x) / float(window::width), 0.f, 1.f); + // Write the model first, then tell the host: the bindings sample the + // current value when broadcasting the automation point. + fx.effect.inputs.gain.value = v; + host.perform_edit(host.ctx, window::gain_param, v); + } + + void paint(HDC dc) + { + RECT rc{0, 0, window::width, window::height}; + const HBRUSH bg = CreateSolidBrush(RGB(30, 30, 34)); + FillRect(dc, &rc, bg); + DeleteObject(bg); + + RECT bar{ + 16, 32, 16 + (int)((window::width - 32) * gain()), window::height - 32}; + const HBRUSH fill = CreateSolidBrush(RGB(255, 176, 30)); + FillRect(dc, &bar, fill); + DeleteObject(fill); + + char label[64]; + std::snprintf(label, sizeof(label), "gain: %.2f", gain()); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, RGB(220, 220, 220)); + TextOutA(dc, 16, 8, label, (int)strlen(label)); + } + + static LRESULT CALLBACK wndproc(HWND h, UINT msg, WPARAM wp, LPARAM lp) + { + if(msg == WM_NCCREATE) + { + auto* cs = (CREATESTRUCTW*)lp; + SetWindowLongPtrW(h, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams); + return DefWindowProcW(h, msg, wp, lp); + } + auto* self = (impl*)GetWindowLongPtrW(h, GWLP_USERDATA); + if(!self) + return DefWindowProcW(h, msg, wp, lp); + + switch(msg) + { + case WM_LBUTTONDOWN: + SetCapture(h); + self->dragging = true; + self->host.begin_edit(self->host.ctx, window::gain_param); + self->set_gain_from((int)(short)LOWORD(lp)); + InvalidateRect(h, nullptr, FALSE); + return 0; + + case WM_MOUSEMOVE: + if(self->dragging) + { + self->set_gain_from((int)(short)LOWORD(lp)); + InvalidateRect(h, nullptr, FALSE); + } + return 0; + + case WM_LBUTTONUP: + if(self->dragging) + { + self->dragging = false; + ReleaseCapture(); + self->host.end_edit(self->host.ctx, window::gain_param); + if(self->owner.send_message) + self->owner.send_message(ui_to_processor{.value = self->gain()}); + } + return 0; + + case WM_TIMER: + InvalidateRect(h, nullptr, FALSE); + return 0; + + case WM_PAINT: { + PAINTSTRUCT ps; + const HDC dc = BeginPaint(h, &ps); + self->paint(dc); + EndPaint(h, &ps); + return 0; + } + + default: + return DefWindowProcW(h, msg, wp, lp); + } + } + + window& owner; + avnd::effect_container& fx; + avnd::gui_host host{}; + HWND hwnd{}; + bool dragging{}; +}; + +CustomUiWindow::ui::window::window(avnd::effect_container& fx) + : self{std::make_unique(*this, fx)} +{ +} + +CustomUiWindow::ui::window::~window() = default; + +void CustomUiWindow::ui::window::open(avnd::gui_parent parent, avnd::gui_host h) +{ + self->open(parent, h); +} + +void CustomUiWindow::ui::window::close() +{ + self->close(); +} + +void CustomUiWindow::ui::window::idle() +{ + self->idle(); +} +} + +#endif diff --git a/examples/Advanced/UI/CustomUiWindow.x11.cpp b/examples/Advanced/UI/CustomUiWindow.x11.cpp new file mode 100644 index 00000000..26ee4967 --- /dev/null +++ b/examples/Advanced/UI/CustomUiWindow.x11.cpp @@ -0,0 +1,178 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +// Tier C editor -- Linux implementation. A raw Xlib window, living behind the +// pimpl forward-declared in CustomUiWindow.hpp. See that header for the +// contract; the Windows sibling is CustomUiWindow.win32.cpp. + +#include "CustomUiWindow.hpp" + +#if defined(__linux__) && __has_include() + +#include +#include +#include + +// Xlib last, so its Bool/Status/None/... macros never leak into the headers +// above; this TU uses none of the colliding identifiers after this point. +#include +#include + +namespace examples +{ +// The whole framework side of the window: it owns its own display connection, +// like a toolkit would; the host only ever hands over the parent window id. +struct CustomUiWindow::ui::window::impl +{ + explicit impl(window& owner, avnd::effect_container& fx) + : owner{owner} + , fx{fx} + { + } + + ~impl() { close(); } + + void open(avnd::gui_parent parent, avnd::gui_host h) + { + host = h; + + dpy = XOpenDisplay(nullptr); + if(!dpy) + return; + const int screen = DefaultScreen(dpy); + win = XCreateSimpleWindow( + dpy, (::Window)(uintptr_t)parent.handle, 0, 0, window::width, + window::height, 0, BlackPixel(dpy, screen), rgb(30, 30, 34)); + XSelectInput( + dpy, win, + ExposureMask | ButtonPressMask | ButtonReleaseMask | PointerMotionMask); + gc = XCreateGC(dpy, win, 0, nullptr); + XMapWindow(dpy, win); + XFlush(dpy); + } + + void close() + { + if(dpy) + { + if(gc) + XFreeGC(dpy, gc); + if(win) + XDestroyWindow(dpy, win); + XCloseDisplay(dpy); + dpy = nullptr; + win = 0; + gc = nullptr; + } + } + + // Host idle tick (effEditIdle, clap.timer-support, VST3 IRunLoop): this is + // the author window's event loop *and* its repaint timer, so host automation + // shows up without any callback plumbing. + void idle() + { + if(!dpy) + return; + while(XPending(dpy)) + { + XEvent ev; + XNextEvent(dpy, &ev); + switch(ev.type) + { + case ButtonPress: + if(ev.xbutton.button == Button1) + { + dragging = true; + host.begin_edit(host.ctx, window::gain_param); + set_gain_from(ev.xbutton.x); + } + break; + case MotionNotify: + if(dragging) + set_gain_from(ev.xmotion.x); + break; + case ButtonRelease: + if(ev.xbutton.button == Button1 && dragging) + { + dragging = false; + host.end_edit(host.ctx, window::gain_param); + if(owner.send_message) + owner.send_message(ui_to_processor{.value = gain()}); + } + break; + default: + break; + } + } + paint(); + } + +private: + static unsigned long rgb(int r, int g, int b) + { + // Direct pixel for the ubiquitous 24-bit TrueColor visual: an example is + // allowed this shortcut, a real toolkit would XAllocColor. + return ((unsigned long)r << 16) | ((unsigned long)g << 8) + | (unsigned long)b; + } + + float gain() const { return fx.effect.inputs.gain.value; } + + void set_gain_from(int x) + { + const float v = std::clamp(float(x) / float(window::width), 0.f, 1.f); + // Write the model first, then tell the host: the bindings sample the + // current value when broadcasting the automation point. + fx.effect.inputs.gain.value = v; + host.perform_edit(host.ctx, window::gain_param, v); + } + + void paint() + { + XSetForeground(dpy, gc, rgb(30, 30, 34)); + XFillRectangle(dpy, win, gc, 0, 0, window::width, window::height); + + XSetForeground(dpy, gc, rgb(255, 176, 30)); + XFillRectangle( + dpy, win, gc, 16, 32, (unsigned)((window::width - 32) * gain()), + window::height - 64); + + char label[64]; + std::snprintf(label, sizeof(label), "gain: %.2f", gain()); + XSetForeground(dpy, gc, rgb(220, 220, 220)); + XDrawString(dpy, win, gc, 16, 20, label, (int)strlen(label)); + XFlush(dpy); + } + + window& owner; + avnd::effect_container& fx; + avnd::gui_host host{}; + Display* dpy{}; + ::Window win{}; + GC gc{}; + bool dragging{}; +}; + +CustomUiWindow::ui::window::window(avnd::effect_container& fx) + : self{std::make_unique(*this, fx)} +{ +} + +CustomUiWindow::ui::window::~window() = default; + +void CustomUiWindow::ui::window::open(avnd::gui_parent parent, avnd::gui_host h) +{ + self->open(parent, h); +} + +void CustomUiWindow::ui::window::close() +{ + self->close(); +} + +void CustomUiWindow::ui::window::idle() +{ + self->idle(); +} +} + +#endif