truce_gpu/editor.rs
1//! GPU editor - wraps `BuiltinEditor` rendering with wgpu + baseview.
2//!
3//! Creates a baseview child window with a wgpu surface. Each frame,
4//! delegates widget rendering to `BuiltinEditor::render_to()` through
5//! the GPU backend, then presents.
6
7#[cfg(feature = "hot-debug")]
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10
11use baseview::{Event, EventStatus, Window, WindowHandler, WindowOpenOptions, WindowScalePolicy};
12
13use truce_core::editor::{Editor, EditorBridge, PluginContext, RawWindowHandle};
14use truce_gui::EditorScale;
15use truce_gui::editor::BuiltinEditor;
16use truce_gui::render::RenderBackend;
17use truce_params::Params;
18
19use crate::backend::WgpuBackend;
20use crate::platform::ParentWindow;
21
22/// GPU-accelerated editor.
23///
24/// On `open()`, creates a baseview child window with a wgpu surface.
25/// If wgpu adapter / surface acquisition fails, `from_window` returns
26/// `None` and `on_frame` becomes a no-op for that session.
27pub struct GpuEditor<P: Params> {
28 inner: Arc<Mutex<BuiltinEditor<P>>>,
29 size: (u32, u32),
30 /// Live content-scale factor (a [`truce_gui::EditorScale`]).
31 /// `set_scale_factor` (host) writes here; the baseview handler
32 /// reads it each frame and updates the `WgpuBackend` scale +
33 /// reconfigures the surface when the value diverges from
34 /// `last_applied_scale`.
35 scale: EditorScale,
36 window: Option<baseview::WindowHandle>,
37}
38
39// SAFETY: `baseview::WindowHandle` holds a raw native window pointer
40// (HWND / NSView / X11 Window) and is therefore not auto-`Send`. Hosts
41// call `Editor::open` / `idle` / `close` from a single dedicated GUI
42// thread, never concurrently and never from the audio thread, so the
43// handle is only ever touched on the thread that created it. The
44// `Editor` trait requires `Send` so the editor can live behind a
45// trait object - this impl asserts that the *type* doesn't escape its
46// thread in practice. All other fields (`Arc<Mutex<...>>`, `(u32,
47// u32)`) are already `Send`.
48unsafe impl<P: Params> Send for GpuEditor<P> {}
49
50impl<P: Params + 'static> GpuEditor<P> {
51 #[must_use]
52 pub fn new(inner: BuiltinEditor<P>) -> Self {
53 let size = inner.size();
54 Self {
55 inner: Arc::new(Mutex::new(inner)),
56 size,
57 scale: EditorScale::new(truce_gui::backing_scale()),
58 window: None,
59 }
60 }
61
62 /// Create from a pre-existing shared reference.
63 /// Used by `HotEditor` to share the inner `BuiltinEditor` so it can
64 /// swap the layout on hot-reload while GPU rendering continues.
65 ///
66 /// # Panics
67 ///
68 /// Panics if the inner mutex is poisoned (a previous holder
69 /// panicked). In normal operation `BuiltinEditor` never panics
70 /// while holding the lock.
71 pub fn new_shared(inner: Arc<Mutex<BuiltinEditor<P>>>) -> Self {
72 let size = inner.lock().unwrap().size();
73 Self {
74 inner,
75 size,
76 scale: EditorScale::new(truce_gui::backing_scale()),
77 window: None,
78 }
79 }
80}
81
82// ---------------------------------------------------------------------------
83// Baseview WindowHandler
84// ---------------------------------------------------------------------------
85
86struct GpuWindowHandler<P: Params> {
87 inner: Arc<Mutex<BuiltinEditor<P>>>,
88 gpu: Option<WgpuBackend>,
89 /// Canonical baseview → `InputEvent` translator. Handles cursor
90 /// tracking, double-click synthesis, and line→pixel scroll
91 /// conversion once for everyone.
92 translator: truce_gui::interaction::BaseviewTranslator,
93 /// Current logical size - used to detect hot-reload size changes.
94 current_size: (u32, u32),
95 /// Bridge handle, retained so we can drive `request_resize` from
96 /// the render loop when hot-reload changes the editor's size.
97 bridge: Arc<dyn EditorBridge>,
98 /// Shared with the parent `GpuEditor`; written by `set_scale_factor`
99 /// (host). `on_frame` compares against `last_applied_scale` and
100 /// reconfigures the wgpu surface + MSAA target via
101 /// `WgpuBackend::set_scale` + `resize` when they diverge.
102 scale: EditorScale,
103 last_applied_scale: f32,
104}
105
106impl<P: Params + 'static> WindowHandler for GpuWindowHandler<P> {
107 fn on_frame(&mut self, _window: &mut Window) {
108 if let Some(ref mut gpu) = self.gpu {
109 // Pick up scale changes that landed in the shared cell
110 // since the last frame - either from a host callback (CLAP
111 // `set_scale`, VST3 `IPlugViewContentScaleSupport`) or from
112 // the OS-driven `Resized` path (see on_event). Logical w×h
113 // is fixed (resize is disallowed per `Editor::can_resize`'s
114 // `false` default); only the logical→physical ratio moves.
115 if let Some(cur_scale) = self.scale.take_change(&mut self.last_applied_scale) {
116 gpu.set_scale(cur_scale);
117 gpu.resize(self.current_size.0, self.current_size.1);
118 }
119
120 if let Ok(mut inner) = self.inner.lock() {
121 #[cfg(feature = "hot-debug")]
122 if !inner.has_context() {
123 static WARNED: AtomicBool = AtomicBool::new(false);
124 if !WARNED.swap(true, Ordering::Relaxed) {
125 eprintln!("[truce-gpu] WARNING: on_frame called but inner has no context");
126 }
127 }
128
129 // Check if the inner editor's size changed (e.g. after hot reload).
130 let new_size = inner.size();
131 if new_size != self.current_size {
132 hot_debug!(
133 "[truce-gpu] size changed: {}x{} -> {}x{}",
134 self.current_size.0,
135 self.current_size.1,
136 new_size.0,
137 new_size.1,
138 );
139 gpu.resize(new_size.0, new_size.1);
140 self.bridge.request_resize(new_size.0, new_size.1);
141 self.current_size = new_size;
142 }
143
144 inner.render_to(gpu);
145 }
146 gpu.present();
147 }
148 }
149
150 fn on_event(&mut self, _window: &mut Window, event: Event) -> EventStatus {
151 match event {
152 Event::Mouse(_) => {
153 let Some(input) = self.translator.translate(&event) else {
154 return EventStatus::Ignored;
155 };
156 if let Ok(mut inner) = self.inner.lock() {
157 inner.dispatch_events(&[input]);
158 }
159 EventStatus::Captured
160 }
161 Event::Window(win) => {
162 if let baseview::WindowEvent::Resized(info) = win {
163 // Logical resize is disallowed (`Editor::can_resize`
164 // is `false`), but the OS-reported *scale* is
165 // authoritative: on Windows the parent HWND queried
166 // at `open()` time can report a different DPI than
167 // the child surface baseview actually creates, and
168 // on every platform dragging across a monitor
169 // boundary needs to land on the new DPI. Write
170 // through to the shared cell so `on_frame`'s
171 // `take_change` path calls `set_scale` + `resize`
172 // at the new scale; logical w×h stays put.
173 self.scale.set(info.scale());
174 truce_gui::platform::note_linux_scale_factor(info.scale());
175 }
176 EventStatus::Ignored
177 }
178 Event::Keyboard(_) => EventStatus::Ignored,
179 }
180 }
181}
182
183// ---------------------------------------------------------------------------
184// Editor trait
185// ---------------------------------------------------------------------------
186
187impl<P: Params + 'static> Editor for GpuEditor<P> {
188 fn size(&self) -> (u32, u32) {
189 // Read live size from the inner editor so hot-reload changes
190 // are reflected when the host queries our size.
191 self.inner.lock().map_or(self.size, |g| g.size())
192 }
193
194 fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
195 // Refresh the shared scale from the parent window - on macOS
196 // this is the live `[NSWindow backingScaleFactor]`, on Windows
197 // the per-monitor DPI from the parent HWND. Any
198 // `set_scale_factor` the host issues *after* open will
199 // overwrite this through the same shared cell.
200 self.scale
201 .set(crate::platform::query_backing_scale(&parent));
202 let system_scale = self.scale.get();
203 let (lw, lh) = self.size; // logical points
204
205 hot_debug!("[truce-gpu] open() called, size={}x{}", lw, lh);
206
207 let bridge = Arc::clone(context.bridge());
208
209 // Set up the inner editor's context for param access
210 if let Ok(mut inner) = self.inner.lock() {
211 inner.set_context(context);
212 hot_debug!("[truce-gpu] context set on inner editor");
213 } else {
214 hot_debug!("[truce-gpu] ERROR: failed to lock inner for set_context");
215 }
216
217 let inner = Arc::clone(&self.inner);
218 let size = self.size;
219 let scale_handle = self.scale.clone();
220
221 let options = WindowOpenOptions {
222 title: String::from("truce-gpu"),
223 size: baseview::Size::new(f64::from(lw), f64::from(lh)),
224 scale: WindowScalePolicy::SystemScaleFactor,
225 };
226
227 let parent_wrapper = ParentWindow(parent);
228
229 let window = baseview::Window::open_parented(
230 &parent_wrapper,
231 options,
232 move |window: &mut Window| {
233 // Display scale never exceeds 4.0 in practice.
234 #[allow(clippy::cast_possible_truncation)]
235 let scale = system_scale as f32;
236 let gpu = unsafe { WgpuBackend::from_window(window, size.0, size.1, scale) };
237
238 GpuWindowHandler {
239 inner,
240 gpu,
241 translator: truce_gui::interaction::BaseviewTranslator::default(),
242 current_size: size,
243 bridge,
244 scale: scale_handle,
245 last_applied_scale: scale,
246 }
247 },
248 );
249
250 self.window = Some(window);
251 }
252
253 fn set_scale_factor(&mut self, factor: f64) {
254 // Write to the shared cell; the baseview handler picks up the
255 // change on its next frame and reconfigures the wgpu surface
256 // + MSAA target via `WgpuBackend::set_scale` + `resize`. The
257 // trait's default no-op would silently swallow host scale
258 // changes for the GPU path.
259 self.scale.set(factor);
260 }
261
262 fn close(&mut self) {
263 if let Some(mut window) = self.window.take() {
264 window.close();
265 }
266 }
267
268 fn idle(&mut self) {
269 // baseview drives its own frame loop via on_frame().
270 }
271
272 fn state_changed(&mut self) {
273 if let Ok(mut inner) = self.inner.lock() {
274 inner.state_changed();
275 }
276 }
277
278 fn screenshot(
279 &mut self,
280 _params: Arc<dyn truce_params::Params>,
281 ) -> Option<(Vec<u8>, u32, u32)> {
282 // Headless render of the inner BuiltinEditor at the live
283 // content scale. Drives the same code path as production
284 // (`render_to` → wgpu RenderBackend), just with a
285 // `WgpuBackend::headless` target instead of a window-bound
286 // one. Used by `truce_test::assert_screenshot::<P>()`.
287 //
288 // The inner BuiltinEditor was already built against the
289 // plugin's `Arc<P>` (which is defaults for a fresh plugin),
290 // so the `params` arg is unused.
291 //
292 // `EditorScale` falls back to `backing_scale()` for pre-open
293 // / headless calls - 2.0 on Retina, 1.0 elsewhere - so the
294 // historical "fixed 2×" behavior is preserved on the macOS
295 // hosts where reference PNGs were originally baked.
296 let mut inner = self.inner.lock().ok()?;
297 let (lw, lh) = inner.size();
298 let scale = self.scale.get_f32();
299 let mut backend = WgpuBackend::headless(lw, lh, scale)?;
300 inner.render_to(&mut backend);
301 let pixels = backend.read_pixels();
302 // Round (rather than truncate) so non-integer DPI scales produce
303 // the same physical resolution the WgpuBackend internally
304 // computed when sizing the headless target.
305 // Window dimensions stay below u32::MAX after scaling.
306 #[allow(
307 clippy::cast_possible_truncation,
308 clippy::cast_sign_loss,
309 clippy::cast_precision_loss
310 )]
311 let (phys_w, phys_h) = (
312 (lw as f32 * scale).round() as u32,
313 (lh as f32 * scale).round() as u32,
314 );
315 Some((pixels, phys_w, phys_h))
316 }
317}