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}