Skip to main content

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}