Skip to main content

truce_iced/
editor.rs

1//! `IcedEditor` - implements `truce_core::Editor` using iced for rendering.
2//!
3//! Drives iced's `UserInterface` directly each frame against a wgpu
4//! surface provided by baseview. Used to lean on
5//! `iced_runtime::program::State` for this; that surface was removed
6//! in iced 0.14, so this module now manages the build / update / draw
7//! / cache cycle inline.
8
9use std::fmt::Debug;
10use std::sync::Arc;
11
12use iced::{Color, Event, Point, Size, Task};
13use iced_wgpu::wgpu;
14use truce_core::editor::{Editor, PluginContext};
15use truce_gui::EditorScale;
16use truce_gui::layout::GridLayout;
17use truce_params::Params;
18
19use crate::auto_layout;
20use crate::param_cache::ParamCache;
21use crate::param_message::{Message, ParamMessage};
22
23// IcedPlugin trait - what plugin authors implement
24
25/// Trait for plugin-specific iced UI logic.
26///
27/// Plugin authors implement this for full control over the iced view.
28/// For zero-code UIs, use `IcedEditor::from_layout()` instead.
29pub trait IcedPlugin<P: Params>: Sized + 'static {
30    /// Plugin-specific message type. Use `()` if you have no custom messages.
31    type Message: Debug + Clone + Send;
32
33    /// Create the initial model.
34    fn new(params: Arc<P>) -> Self;
35
36    /// Handle a message (param change or plugin-specific).
37    /// Default: no-op.
38    fn update(
39        &mut self,
40        _message: Message<Self::Message>,
41        _params: &ParamCache<P>,
42        _ctx: &PluginContext<P>,
43    ) -> Task<Message<Self::Message>> {
44        Task::none()
45    }
46
47    /// Build the view.
48    fn view<'a>(&'a self, params: &'a ParamCache<P>) -> iced::Element<'a, Message<Self::Message>>;
49
50    /// Custom theme (default: truce dark).
51    fn theme(&self) -> iced::Theme {
52        crate::theme::truce_dark_theme()
53    }
54
55    /// Window title.
56    fn title(&self) -> String {
57        String::from("Plugin")
58    }
59
60    /// Plugin state was restored (preset recall, undo, session load).
61    /// Re-read any cached custom state. Parameter values update automatically.
62    fn state_changed(&mut self) {}
63}
64
65// AutoPlugin - built-in plugin for GridLayout auto mode
66
67/// Built-in `IcedPlugin` that generates a view from a `GridLayout`.
68pub struct AutoPlugin {
69    layout: GridLayout,
70}
71
72impl<P: Params> IcedPlugin<P> for AutoPlugin {
73    type Message = (); // No custom messages in auto mode
74
75    fn new(_params: Arc<P>) -> Self {
76        panic!("AutoPlugin must be created via IcedEditor::from_layout");
77    }
78
79    fn view<'a>(&'a self, params: &'a ParamCache<P>) -> iced::Element<'a, Message<()>> {
80        auto_layout::auto_view(&self.layout, params)
81    }
82}
83
84// IcedProgram - holds the plugin model + the shadow state the runtime
85// reads / writes each frame. Used to implement `iced_runtime::Program`,
86// but that trait no longer exists in iced 0.14; the runtime drives
87// this type directly via `dispatch` / `view`.
88
89pub(crate) struct IcedProgram<P: Params + 'static, M: IcedPlugin<P>> {
90    pub(crate) plugin: M,
91    pub(crate) param_cache: ParamCache<P>,
92    pub(crate) context: PluginContext<P>,
93    pub(crate) meter_ids: Vec<u32>,
94}
95
96impl<P: Params + 'static, M: IcedPlugin<P>> IcedProgram<P, M> {
97    fn apply_param_message(&self, msg: &ParamMessage) {
98        match msg {
99            ParamMessage::BeginEdit(id) => self.context.begin_edit(*id),
100            ParamMessage::SetNormalized(id, val) => self.context.set_param(*id, *val),
101            ParamMessage::EndEdit(id) => self.context.end_edit(*id),
102            ParamMessage::Batch(msgs) => {
103                for m in msgs {
104                    self.apply_param_message(m);
105                }
106            }
107        }
108    }
109
110    /// Handle a single message: forward param events to the host, sync
111    /// the shadow cache on `Tick`, and otherwise hand the message to
112    /// the plugin's own `update`. The plugin may return a `Task` -
113    /// truce-iced doesn't run an executor for embedded use, so the
114    /// task is dropped. Plugin code that needs async work should
115    /// thread it through its own host hooks rather than relying on
116    /// iced's task runtime.
117    pub(crate) fn dispatch(&mut self, message: Message<M::Message>) {
118        if let Message::Param(ref param_msg) = message {
119            self.apply_param_message(param_msg);
120        }
121
122        match message {
123            Message::Tick => {
124                self.param_cache.sync(&self.context);
125                self.param_cache.sync_meters(&self.context, &self.meter_ids);
126            }
127            other => {
128                let _: Task<Message<M::Message>> =
129                    self.plugin.update(other, &self.param_cache, &self.context);
130            }
131        }
132    }
133
134    pub(crate) fn view(&self) -> iced::Element<'_, Message<M::Message>> {
135        self.plugin.view(&self.param_cache)
136    }
137}
138
139// IcedEditor - main entry point, implements truce_core::Editor
140
141/// Iced-based plugin editor.
142///
143/// Type parameters:
144/// - `P` - the plugin's `Params` type
145/// - `M` - the plugin's `IcedPlugin` implementation
146pub struct IcedEditor<P, M>
147where
148    P: Params + 'static,
149    M: IcedPlugin<P>,
150{
151    params: Arc<P>,
152    size: (u32, u32),
153    /// Live content-scale factor, shared with the runtime via
154    /// [`truce_gui::EditorScale`]. Both `set_scale_factor` (host) and
155    /// the baseview `Resized` handler write here; the runtime's
156    /// `tick()` reads it and reconfigures the surface/viewport when it
157    /// diverges from `last_applied_scale`.
158    scale: EditorScale,
159    font: Option<(&'static str, &'static [u8])>,
160    runtime: Option<IcedRuntime<P, M>>,
161    /// Constructor closure for the plugin model. Each constructor
162    /// stores a closure that produces an `M` of the correct concrete
163    /// type:
164    /// - `from_layout` captures the `GridLayout` and returns
165    ///   `AutoPlugin { layout: layout.clone() }` (the `impl` block
166    ///   fixes `M = AutoPlugin`).
167    /// - `new` defers to `M::new(params)`.
168    ///
169    /// `Fn` (not `FnOnce`) so `open()` and `screenshot()` can each
170    /// produce a fresh `M`. Hosts that destroy and recreate the editor
171    /// (CLAP `gui_destroy` / `gui_create`) call `open()` more than once;
172    /// `screenshot()` builds a separate offscreen iced program. The
173    /// closure also carries the construction invariant for `AutoPlugin`,
174    /// whose `IcedPlugin::new` is `panic!("must be created via
175    /// from_layout")` - going through `M::new` instead would panic on
176    /// the screenshot path.
177    make_plugin: Box<dyn Fn(Arc<P>) -> M + Send + Sync>,
178    meter_ids: Vec<u32>,
179    baseview_window: Option<baseview::WindowHandle>,
180}
181
182// SAFETY: `baseview::WindowHandle` holds a raw native window pointer
183// (HWND / NSView / X11 Window) and is not auto-`Send`. Hosts call
184// `Editor::open` / `idle` / `close` from a single dedicated GUI thread
185// - never concurrently and never from the audio thread - so the
186// handle is only ever touched on the thread that created it. The
187// `Editor` trait requires `Send` so the editor can live behind a
188// trait object; this impl asserts that the type doesn't escape its
189// thread in practice. The `make_plugin` boxed closure is already
190// `Send`-bounded; runtime / meter_ids / size are trivially `Send`.
191unsafe impl<P: Params, M: IcedPlugin<P>> Send for IcedEditor<P, M> {}
192
193impl<P: Params + 'static, M: IcedPlugin<P> + 'static> Drop for IcedEditor<P, M> {
194    /// Defensive cleanup for hosts that drop the editor without first
195    /// calling `Editor::close`. Pro Tools AAX has been seen to do this
196    /// on plugin removal under certain conditions; live-coding hosts
197    /// and unit tests can also short-circuit the lifecycle. On Linux
198    /// `baseview::WindowHandle` has no `Drop`, so without an explicit
199    /// `close` the render thread would keep running against a freed
200    /// `*mut IcedEditor` and later panic inside wgpu as surfaces tear
201    /// down. `close()` is idempotent - `baseview_window.take()`
202    /// no-ops on the second call - so calling it here on top of a
203    /// well-behaved host's earlier `close()` is safe.
204    fn drop(&mut self) {
205        Editor::close(self);
206    }
207}
208
209impl<P: Params + 'static> IcedEditor<P, AutoPlugin> {
210    /// Create an editor that auto-generates the UI from a `GridLayout`.
211    pub fn from_layout(params: Arc<P>, layout: GridLayout) -> Self {
212        let size = (layout.width, layout.height);
213        let meter_ids: Vec<u32> = layout
214            .widgets
215            .iter()
216            .filter_map(|w| w.meter_ids.as_ref())
217            .flatten()
218            .copied()
219            .collect();
220
221        let make_plugin: Box<dyn Fn(Arc<P>) -> AutoPlugin + Send + Sync> =
222            Box::new(move |_params| AutoPlugin {
223                layout: layout.clone(),
224            });
225
226        Self {
227            params,
228            size,
229            scale: EditorScale::new(truce_gui::backing_scale()),
230            font: None,
231            runtime: None,
232            make_plugin,
233            meter_ids,
234            baseview_window: None,
235        }
236    }
237}
238
239impl<P: Params + 'static, M: IcedPlugin<P> + 'static> IcedEditor<P, M> {
240    /// Create an editor with a custom `IcedPlugin` implementation.
241    pub fn new(params: Arc<P>, size: (u32, u32)) -> Self {
242        Self {
243            params,
244            size,
245            scale: EditorScale::new(truce_gui::backing_scale()),
246            font: None,
247            runtime: None,
248            make_plugin: Box::new(|p| M::new(p)),
249            meter_ids: Vec::new(),
250            baseview_window: None,
251        }
252    }
253
254    /// Set a custom default font (family name + TrueType data).
255    ///
256    /// ```ignore
257    /// IcedEditor::new(params, (250, 330))
258    ///     .with_font("JetBrains Mono", truce_gui::font::JETBRAINS_MONO)
259    /// ```
260    #[must_use]
261    pub fn with_font(mut self, family: &'static str, data: &'static [u8]) -> Self {
262        self.font = Some((family, data));
263        self
264    }
265
266    /// Set meter IDs to poll each tick.
267    #[must_use]
268    pub fn with_meter_ids(mut self, ids: Vec<impl Into<u32>>) -> Self {
269        self.meter_ids = ids.into_iter().map(std::convert::Into::into).collect();
270        self
271    }
272}
273
274// IcedRuntime - active iced state (exists only while editor is open)
275
276struct IcedRuntime<P: Params, M: IcedPlugin<P>> {
277    /// Rendering pipeline - initialized lazily when the baseview window
278    /// finishes building and a wgpu surface is available.
279    render: Option<RenderState<P, M>>,
280    /// Current cursor position in logical coordinates.
281    cursor_position: Point,
282    /// Pending iced events queued by mouse callbacks.
283    pending_events: Vec<Event>,
284    /// Plugin creation info (consumed during render init).
285    program: Option<IcedProgram<P, M>>,
286    /// Editor size for viewport.
287    size: (u32, u32),
288    /// Live scale factor (clone of the editor's). Source of truth for
289    /// every render path; written by `Editor::set_scale_factor` and
290    /// the baseview `Resized` handler, observed each `tick()`.
291    scale: EditorScale,
292    /// Last scale value the surface/viewport were configured for. When
293    /// `scale.get()` diverges from this, `tick()` reconfigures and
294    /// updates this snapshot.
295    last_applied_scale: f64,
296    /// Custom font (family name, TrueType data).
297    font: Option<(&'static str, &'static [u8])>,
298}
299
300/// Holds the full wgpu + iced rendering pipeline.
301///
302/// Replaces what `iced_runtime::program::State` used to encapsulate
303/// in our 0.13 setup: we own the plugin model + the `UserInterface`
304/// cache that lets iced reuse layout work between frames, and drive
305/// the build / update / draw / extract-cache cycle by hand each
306/// `tick()`.
307struct RenderState<P: Params + 'static, M: IcedPlugin<P>> {
308    /// Cloned wgpu handle for surface (re)configuration. The "primary"
309    /// device + queue handles live inside `renderer`'s `Engine`.
310    device: wgpu::Device,
311    surface: wgpu::Surface<'static>,
312    surface_config: wgpu::SurfaceConfiguration,
313    renderer: iced_wgpu::Renderer,
314    program: IcedProgram<P, M>,
315    /// `iced_runtime::UserInterface` cache between frames. Holds widget
316    /// internal state (focus, scroll positions, ...) so we don't lose
317    /// it between layout passes. `None` only mid-`tick()` between
318    /// build and extract.
319    ui_cache: Option<iced_runtime::user_interface::Cache>,
320    /// Most recent mouse interaction reported by the UI's draw pass.
321    /// Polled by the baseview handler to update the OS cursor.
322    interaction: iced::mouse::Interaction,
323    viewport: iced_graphics::Viewport,
324    theme: iced::Theme,
325    bg_color: Color,
326}
327
328impl<P: Params + 'static, M: IcedPlugin<P>> IcedRuntime<P, M> {
329    /// Initialize the wgpu + iced rendering pipeline from a pre-created surface.
330    //
331    // `instance` and `surface` are threaded into the iced renderer; the
332    // owned-arg shape avoids a clone at the call site.
333    #[allow(clippy::needless_pass_by_value)]
334    fn init_render(&mut self, instance: wgpu::Instance, surface: wgpu::Surface<'static>) -> bool {
335        let Some(program) = self.program.take() else {
336            return false;
337        };
338
339        let (lw, lh) = self.size;
340        // Read from the shared cell (clone of the editor's scale). Re-
341        // querying `truce_gui::backing_scale()` would drop a host-
342        // supplied value and on Linux the process-wide cache may not
343        // have been populated yet, so the first frame would render at
344        // 1.0 even on a HiDPI display.
345        let render_scale = self.scale.get();
346        self.last_applied_scale = render_scale;
347        let w = truce_gui::to_physical_px(lw, render_scale);
348        let h = truce_gui::to_physical_px(lh, render_scale);
349
350        let adapter =
351            match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
352                power_preference: wgpu::PowerPreference::HighPerformance,
353                compatible_surface: Some(&surface),
354                force_fallback_adapter: false,
355            })) {
356                Ok(a) => a,
357                Err(e) => {
358                    log::warn!("no suitable GPU adapter found: {e}");
359                    self.program = Some(program);
360                    return false;
361                }
362            };
363
364        let (device, queue) =
365            match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
366                label: Some("truce-iced"),
367                required_features: wgpu::Features::empty(),
368                required_limits: wgpu::Limits::downlevel_defaults(),
369                experimental_features: wgpu::ExperimentalFeatures::default(),
370                memory_hints: wgpu::MemoryHints::default(),
371                trace: wgpu::Trace::Off,
372            })) {
373                Ok(dq) => dq,
374                Err(e) => {
375                    log::error!("failed to create wgpu device: {e}");
376                    self.program = Some(program);
377                    return false;
378                }
379            };
380
381        let surface_caps = surface.get_capabilities(&adapter);
382        if surface_caps.formats.is_empty() {
383            log::warn!("no surface formats available");
384            self.program = Some(program);
385            return false;
386        }
387
388        let surface_format = surface_caps.formats[0];
389        let alpha_mode = if surface_caps
390            .alpha_modes
391            .contains(&wgpu::CompositeAlphaMode::PostMultiplied)
392        {
393            wgpu::CompositeAlphaMode::PostMultiplied
394        } else {
395            surface_caps.alpha_modes[0]
396        };
397
398        let surface_config = wgpu::SurfaceConfiguration {
399            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
400            format: surface_format,
401            width: w.max(1),
402            height: h.max(1),
403            present_mode: wgpu::PresentMode::AutoVsync,
404            desired_maximum_frame_latency: 2,
405            alpha_mode,
406            view_formats: vec![],
407        };
408        surface.configure(&device, &surface_config);
409
410        // wgpu::Device / Queue are cheaply Clone-able (internally Arc'd);
411        // hand the canonical pair to `Engine::new` and keep clones for
412        // post-init surface reconfiguration.
413        let surface_device = device.clone();
414        let engine = iced_wgpu::Engine::new(
415            &adapter,
416            device,
417            queue,
418            surface_format,
419            Some(iced_graphics::Antialiasing::MSAAx4),
420            iced_graphics::Shell::headless(),
421        );
422
423        let default_font = if let Some((family, data)) = self.font {
424            crate::font::apply_font(family, data)
425        } else {
426            iced::Font::DEFAULT
427        };
428        let renderer = iced_wgpu::Renderer::new(engine, default_font, iced::Pixels(14.0));
429
430        // Scale is a display DPI factor (typically 1.0..=3.0); the
431        // narrowing here is a documented host convention loss, not a
432        // numeric overflow.
433        #[allow(clippy::cast_possible_truncation)]
434        let viewport =
435            iced_graphics::Viewport::with_physical_size(Size::new(w, h), render_scale as f32);
436        let theme = program.plugin.theme();
437
438        let bg = crate::theme::truce_dark_theme().palette().background;
439
440        self.render = Some(RenderState {
441            device: surface_device,
442            surface,
443            surface_config,
444            renderer,
445            program,
446            ui_cache: Some(iced_runtime::user_interface::Cache::new()),
447            interaction: iced::mouse::Interaction::default(),
448            viewport,
449            theme,
450            bg_color: bg,
451        });
452
453        log::info!("gpu active (wgpu, {w}x{h})");
454        true
455    }
456
457    /// Drive one frame: update iced state + present to surface.
458    fn tick(&mut self) {
459        let Some(render) = self.render.as_mut() else {
460            return;
461        };
462
463        // Pick up host-driven scale changes (CLAP `set_scale`, VST3
464        // `IPlugViewContentScaleSupport`) that landed in the shared
465        // cell since the last frame. The Resized path applies its own
466        // scale changes inline so this branch only fires when scale
467        // moved without a corresponding window event.
468        //
469        // Bit-level comparison rather than `!=` so the implicit
470        // invariant - "values come through `EditorScale::set` /
471        // `.get()`, both of which round-trip via `to_bits` /
472        // `from_bits`, so equal inputs produce equal stored bits" -
473        // is explicit at the comparison site. `2.0 != 2.0` would
474        // never be true via this path today, but a clippy lint and
475        // a future refactor that narrowed the type to `f32` somewhere
476        // could turn the implicit guarantee into an actual NaN-flavored
477        // bug.
478        let cur_scale = self.scale.get();
479        if cur_scale.to_bits() != self.last_applied_scale.to_bits() {
480            let (lw, lh) = self.size;
481            let pw = truce_gui::to_physical_px(lw, cur_scale);
482            let ph = truce_gui::to_physical_px(lh, cur_scale);
483            render.surface_config.width = pw;
484            render.surface_config.height = ph;
485            render
486                .surface
487                .configure(&render.device, &render.surface_config);
488            #[allow(clippy::cast_possible_truncation)] // display DPI; bounded
489            let scale_f32 = cur_scale as f32;
490            render.viewport =
491                iced_graphics::Viewport::with_physical_size(Size::new(pw, ph), scale_f32);
492            self.last_applied_scale = cur_scale;
493        }
494
495        // Process the per-frame "sync params and meters" tick + any
496        // events queued by baseview before we touch iced. Tick first so
497        // the view rebuilt below sees fresh shadow values; events after
498        // are folded into the same UserInterface update pass.
499        render.program.dispatch(Message::Tick);
500
501        let cursor = iced::mouse::Cursor::Available(self.cursor_position);
502        let logical_size = render.viewport.logical_size();
503        let style = iced_runtime::core::renderer::Style {
504            text_color: Color::from_rgb(0.90, 0.90, 0.92),
505        };
506
507        // Build the user interface for this frame from the current
508        // model. The borrow of `render.program` is dropped at
509        // `into_cache()`, after which we can re-enter `dispatch` for
510        // each collected message.
511        let mut messages: Vec<Message<M::Message>> = Vec::new();
512        let cache = render
513            .ui_cache
514            .take()
515            .unwrap_or_else(iced_runtime::user_interface::Cache::new);
516        let view_element = render.program.view();
517        let mut user_interface = iced_runtime::UserInterface::build(
518            view_element,
519            logical_size,
520            cache,
521            &mut render.renderer,
522        );
523
524        let pending_events = std::mem::take(&mut self.pending_events);
525        let (ui_state, _statuses) = user_interface.update(
526            &pending_events,
527            cursor,
528            &mut render.renderer,
529            &mut iced_runtime::core::clipboard::Null,
530            &mut messages,
531        );
532        // `UserInterface::update` is where the mouse interaction is
533        // reported in iced 0.14 (0.13 returned it from `draw`).
534        // `Outdated` means the widget tree changed and we'd want to
535        // rebuild for accuracy; defer to the next frame and keep the
536        // previous interaction value in the meantime.
537        if let iced_runtime::user_interface::State::Updated {
538            mouse_interaction, ..
539        } = ui_state
540        {
541            render.interaction = mouse_interaction;
542        }
543
544        user_interface.draw(&mut render.renderer, &render.theme, &style, cursor);
545
546        render.ui_cache = Some(user_interface.into_cache());
547
548        // Now we can mutate the program again - drain any messages the
549        // event handlers produced.
550        for message in messages {
551            render.program.dispatch(message);
552        }
553
554        // Present: get surface texture, render, submit. iced 0.14's
555        // `Renderer::present` builds its own encoder + submits to the
556        // queue internally, so we no longer manage either by hand.
557        let frame = match render.surface.get_current_texture() {
558            Ok(f) => f,
559            Err(wgpu::SurfaceError::Timeout | wgpu::SurfaceError::Outdated) => {
560                render
561                    .surface
562                    .configure(&render.device, &render.surface_config);
563                return;
564            }
565            Err(e) => {
566                log::warn!("surface error: {e}");
567                return;
568            }
569        };
570
571        let view = frame
572            .texture
573            .create_view(&wgpu::TextureViewDescriptor::default());
574
575        let _ = render.renderer.present(
576            Some(render.bg_color),
577            render.surface_config.format,
578            &view,
579            &render.viewport,
580        );
581
582        frame.present();
583    }
584
585    /// Queue a cursor move event. Coordinates are in logical points.
586    fn queue_cursor_move(&mut self, x: f32, y: f32) {
587        self.cursor_position = Point::new(x, y);
588        self.pending_events
589            .push(Event::Mouse(iced::mouse::Event::CursorMoved {
590                position: self.cursor_position,
591            }));
592    }
593}
594
595// Baseview window handler (all platforms)
596
597struct IcedBaseviewHandler<P: Params + 'static, M: IcedPlugin<P>> {
598    editor: *mut IcedEditor<P, M>,
599    last_cursor: Option<baseview::MouseCursor>,
600}
601
602// SAFETY: The raw `*mut IcedEditor<P, M>` is only dereferenced from
603// the baseview event thread (which `WindowHandler` is bound to). The
604// editor's host-side close path joins this thread before dropping the
605// editor, so the pointer is always valid while `WindowHandler`
606// methods run. baseview requires `Send` for its handler types so that
607// the handler can be moved onto the dedicated event thread on
608// construction; once moved, it never crosses threads again.
609unsafe impl<P: Params, M: IcedPlugin<P>> Send for IcedBaseviewHandler<P, M> {}
610
611impl<P: Params + 'static, M: IcedPlugin<P>> Drop for IcedBaseviewHandler<P, M> {
612    fn drop(&mut self) {
613        // Drop wgpu/iced render state on the baseview event thread, while
614        // any underlying display connection (e.g. X11 Display via XcbConnection)
615        // is still alive. If we let the host-thread close() path drop
616        // `runtime.render` instead, NVIDIA's Vulkan surface-destruction code
617        // tries to use a freed Display and segfaults inside _XSend.
618        //
619        // Safety: close() always calls window.close() which joins this
620        // thread before returning. While this drop runs, the host thread
621        // is blocked in join(), so `self.editor` is still valid.
622        let editor = unsafe { &mut *self.editor };
623        if let Some(ref mut runtime) = editor.runtime {
624            runtime.render = None;
625        }
626    }
627}
628
629// The explicit `Idle | None => Default` arm documents iced's known
630// no-cursor states; the trailing `_ => Default` keeps forward-compat
631// against future iced enum variants. Both intentionally share the
632// value.
633#[allow(clippy::match_same_arms)]
634fn iced_interaction_to_cursor(interaction: iced::mouse::Interaction) -> baseview::MouseCursor {
635    use iced::mouse::Interaction;
636    match interaction {
637        Interaction::Idle | Interaction::None => baseview::MouseCursor::Default,
638        Interaction::Pointer | Interaction::Grab => baseview::MouseCursor::Hand,
639        Interaction::Grabbing => baseview::MouseCursor::HandGrabbing,
640        Interaction::Text => baseview::MouseCursor::Text,
641        Interaction::Crosshair => baseview::MouseCursor::Crosshair,
642        Interaction::NotAllowed => baseview::MouseCursor::NotAllowed,
643        Interaction::ResizingHorizontally => baseview::MouseCursor::EwResize,
644        Interaction::ResizingVertically => baseview::MouseCursor::NsResize,
645        _ => baseview::MouseCursor::Default,
646    }
647}
648
649impl<P: Params + 'static, M: IcedPlugin<P>> baseview::WindowHandler for IcedBaseviewHandler<P, M> {
650    fn on_frame(&mut self, window: &mut baseview::Window) {
651        let editor = unsafe { &mut *self.editor };
652        if let Some(ref mut runtime) = editor.runtime {
653            runtime.tick();
654            if let Some(ref render) = runtime.render {
655                let cursor = iced_interaction_to_cursor(render.interaction);
656                if self.last_cursor != Some(cursor) {
657                    self.last_cursor = Some(cursor);
658                    window.set_mouse_cursor(cursor);
659                }
660            }
661        }
662    }
663
664    fn on_event(
665        &mut self,
666        #[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
667        window: &mut baseview::Window,
668        event: baseview::Event,
669    ) -> baseview::EventStatus {
670        let editor = unsafe { &mut *self.editor };
671        let Some(runtime) = editor.runtime.as_mut() else {
672            return baseview::EventStatus::Ignored;
673        };
674
675        match event {
676            baseview::Event::Mouse(mouse) => {
677                match mouse {
678                    baseview::MouseEvent::CursorMoved { position, .. } => {
679                        // baseview reports logical points; iced widgets
680                        // hit-test in logical units against
681                        // `viewport.logical_size()`, so forward as-is.
682                        // Window dimensions stay well below 2^23 - the
683                        // f64 → f32 narrowing is invisible.
684                        #[allow(clippy::cast_possible_truncation)]
685                        let pos = (position.x as f32, position.y as f32);
686                        runtime.queue_cursor_move(pos.0, pos.1);
687                    }
688                    baseview::MouseEvent::CursorLeft => {
689                        runtime
690                            .pending_events
691                            .push(Event::Mouse(iced::mouse::Event::CursorLeft));
692                    }
693                    baseview::MouseEvent::ButtonPressed {
694                        button: baseview::MouseButton::Left,
695                        ..
696                    } => {
697                        // WS_CHILD plugin windows don't receive WM_KEYDOWN
698                        // until focused; baseview doesn't SetFocus on click,
699                        // so we do it here. Without this, text-edit widgets
700                        // never see keystrokes on Windows.
701                        #[cfg(target_os = "windows")]
702                        {
703                            if !window.has_focus() {
704                                window.focus();
705                            }
706                        }
707                        runtime.pending_events.push(Event::Mouse(
708                            iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left),
709                        ));
710                    }
711                    baseview::MouseEvent::ButtonReleased {
712                        button: baseview::MouseButton::Left,
713                        ..
714                    } => {
715                        runtime.pending_events.push(Event::Mouse(
716                            iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
717                        ));
718                    }
719                    baseview::MouseEvent::WheelScrolled { delta, .. } => {
720                        let dy = match delta {
721                            baseview::ScrollDelta::Lines { y, .. } => y * 30.0,
722                            baseview::ScrollDelta::Pixels { y, .. } => y,
723                        };
724                        runtime.pending_events.push(Event::Mouse(
725                            iced::mouse::Event::WheelScrolled {
726                                delta: iced::mouse::ScrollDelta::Pixels { x: 0.0, y: dy },
727                            },
728                        ));
729                    }
730                    _ => return baseview::EventStatus::Ignored,
731                }
732                baseview::EventStatus::Captured
733            }
734            baseview::Event::Window(baseview::WindowEvent::Resized(info)) => {
735                crate::platform::note_linux_scale_factor(info.scale());
736                // Mirror the OS-reported scale into the shared cell
737                // (so a follow-up `set_scale_factor` from the host
738                // reads a fresh baseline) and bump `last_applied_scale`
739                // so `tick()`'s diff-check stays a no-op - we apply
740                // the reconfigure inline below.
741                runtime.scale.set(info.scale());
742                runtime.last_applied_scale = info.scale();
743                if let Some(ref mut render) = runtime.render {
744                    let pw = info.physical_size().width;
745                    let ph = info.physical_size().height;
746                    render.surface_config.width = pw.max(1);
747                    render.surface_config.height = ph.max(1);
748                    render
749                        .surface
750                        .configure(&render.device, &render.surface_config);
751                    #[allow(clippy::cast_possible_truncation)] // display DPI; bounded
752                    let scale_f32 = info.scale() as f32;
753                    render.viewport =
754                        iced_graphics::Viewport::with_physical_size(Size::new(pw, ph), scale_f32);
755                }
756                baseview::EventStatus::Captured
757            }
758            _ => baseview::EventStatus::Ignored,
759        }
760    }
761}
762
763// Editor trait implementation
764
765impl<P: Params + 'static, M: IcedPlugin<P>> Editor for IcedEditor<P, M> {
766    fn size(&self) -> (u32, u32) {
767        self.size
768    }
769
770    fn open(&mut self, parent: truce_core::editor::RawWindowHandle, context: PluginContext) {
771        let (w, h) = self.size;
772
773        // Create the plugin model. The closure is `Fn`, not `FnOnce`,
774        // so destroy/recreate cycles (CLAP `gui_destroy` / `gui_create`,
775        // some VST3 hosts that close+reopen the editor) reuse it.
776        let plugin = (self.make_plugin)(self.params.clone());
777
778        let mut param_cache = ParamCache::new(self.params.clone());
779        if let Some((family, _)) = self.font {
780            param_cache.set_font(iced::Font {
781                family: iced::font::Family::Name(family),
782                ..iced::Font::DEFAULT
783            });
784        }
785        let typed_ctx = context.with_params(self.params.clone());
786        let program = IcedProgram {
787            plugin,
788            param_cache,
789            context: typed_ctx,
790            meter_ids: self.meter_ids.clone(),
791        };
792
793        self.runtime = Some(IcedRuntime {
794            render: None,
795            cursor_position: Point::ORIGIN,
796            pending_events: Vec::new(),
797            program: Some(program),
798            size: (w, h),
799            scale: self.scale.clone(),
800            // init_render writes the real value; this placeholder
801            // never reaches a render call.
802            last_applied_scale: 0.0,
803            font: self.font,
804        });
805
806        let parent_wrapper = crate::platform::ParentWindow(parent);
807        let options = baseview::WindowOpenOptions {
808            title: String::from("truce-iced"),
809            size: baseview::Size::new(f64::from(w), f64::from(h)),
810            scale: baseview::WindowScalePolicy::SystemScaleFactor,
811        };
812
813        let editor_addr = std::ptr::from_mut::<IcedEditor<P, M>>(self) as usize;
814
815        let window = baseview::Window::open_parented(
816            &parent_wrapper,
817            options,
818            move |window: &mut baseview::Window| {
819                let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
820                    backends: wgpu::Backends::PRIMARY,
821                    ..Default::default()
822                });
823
824                let surface = unsafe { crate::platform::create_wgpu_surface(&instance, window) };
825
826                if let Some(surface) = surface {
827                    let editor = unsafe { &mut *(editor_addr as *mut IcedEditor<P, M>) };
828                    if let Some(ref mut runtime) = editor.runtime {
829                        runtime.init_render(instance, surface);
830                    }
831                }
832
833                IcedBaseviewHandler::<P, M> {
834                    editor: editor_addr as *mut IcedEditor<P, M>,
835                    last_cursor: None,
836                }
837            },
838        );
839
840        self.baseview_window = Some(window);
841        log::info!("editor opened via baseview ({w}x{h})");
842    }
843
844    fn close(&mut self) {
845        // baseview's Linux WindowHandle has no Drop impl - we must call
846        // close() explicitly to request shutdown and join the render
847        // thread. Without this, the thread keeps running against a
848        // dangling self pointer after the host drops this editor, which
849        // later panics inside wgpu as surfaces get torn down.
850        if let Some(mut window) = self.baseview_window.take() {
851            window.close();
852        }
853        self.runtime = None;
854        log::info!("editor closed");
855    }
856
857    fn idle(&mut self) {
858        // baseview drives its own frame loop via on_frame().
859    }
860
861    fn can_resize(&self) -> bool {
862        true
863    }
864
865    fn screenshot(
866        &mut self,
867        _params: Arc<dyn truce_params::Params>,
868    ) -> Option<(Vec<u8>, u32, u32)> {
869        // Build the plugin via the editor's own constructor closure.
870        // Calling `M::new` directly would panic for `AutoPlugin` -
871        // `from_layout` captures the `GridLayout` in the closure and
872        // the `IcedPlugin::new` impl on `AutoPlugin` is `panic!("must
873        // be created via from_layout")`.
874        let plugin = (self.make_plugin)(Arc::clone(&self.params));
875        // Match the live editor's content scale so the screenshot
876        // exercises the same render path the user sees. `EditorScale`
877        // falls back to `backing_scale()` for pre-open / headless
878        // calls.
879        let scale = self.scale.get();
880        crate::screenshot::render_to_pixels::<P, M>(
881            Arc::clone(&self.params),
882            plugin,
883            self.size,
884            scale,
885            self.font,
886        )
887    }
888
889    fn set_size(&mut self, width: u32, height: u32) -> bool {
890        self.size = (width, height);
891        if let Some(ref mut runtime) = self.runtime {
892            runtime.size = (width, height);
893            if let Some(ref mut render) = runtime.render {
894                let scale = self.scale.get();
895                let pw = truce_gui::to_physical_px(width, scale);
896                let ph = truce_gui::to_physical_px(height, scale);
897                #[allow(clippy::cast_possible_truncation)] // display DPI; bounded
898                let scale_f32 = scale as f32;
899                render.viewport =
900                    iced_graphics::Viewport::with_physical_size(Size::new(pw, ph), scale_f32);
901                render.surface_config.width = pw;
902                render.surface_config.height = ph;
903                render
904                    .surface
905                    .configure(&render.device, &render.surface_config);
906            }
907        }
908        true
909    }
910
911    fn set_scale_factor(&mut self, factor: f64) {
912        // Write to the shared cell; the runtime's `tick()` picks up the
913        // change on its next frame and reconfigures the surface and
914        // viewport.
915        self.scale.set(factor);
916    }
917}