Skip to content

Commit

Permalink
Window related updates
Browse files Browse the repository at this point in the history
- The window state (visibility and bounds) gets preserved when created via context menu. It will not get preserved when created via the ShowUI node.
- Fixes empty crash for some plugins after minimizing (reported window size is zero which some plugins don't like)
- The classes/records EffectHost, PluginState, WindowState are now advanced to not pollute the node browser
  • Loading branch information
azeno committed Dec 10, 2024
1 parent 4791118 commit 5162951
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 60 deletions.
136 changes: 93 additions & 43 deletions src/EffectHost.UI.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,52 @@
using Microsoft.Extensions.Logging;
using System.Drawing;
using System.Windows.Forms;
using VST3;
using System.Runtime.InteropServices.Marshalling;
using System.Reactive.Linq;
using System.Reactive.Disposables;
using VL.Lang.PublicAPI;
using VL.Audio.VST.Internal;
using VST3.Hosting;
using System.Drawing;
using RectangleF = Stride.Core.Mathematics.RectangleF;
using VL.Core;

namespace VL.Audio.VST;

partial class EffectHost
{
private Form? window;
private Window? window;

/// <summary>
/// Shows the UI of the plugin (if available).
/// </summary>
/// <remarks>
/// In case the window was already created via the context menu, it will get re-created to ensure that its bounds are no longer saved to the pin.
/// </remarks>
/// <param name="bounds">The bounds of the window in pixel. Width and height will only get applied if the plugin allows it.</param>
public void ShowUI(Optional<RectangleF> bounds)
{
// Called from user code, don't track window state
ShowUI(bounds.ToNullable(), formWindowState: FormWindowState.Normal, trackWindowState: false);
}

public void ShowUI()
private void ShowUI(RectangleF? bounds, FormWindowState? formWindowState, bool trackWindowState)
{
if (controller is null)
return;

// Ensure tracking state matches the requested state
if (window != null && window.IsTracking != trackWindowState)
{
HideUI();
}

if (window is null || window.IsDisposed)
{
var view = controller.createView("editor");
if (view is null || view.isPlatformTypeSupported("HWND") != 0)
return;

window = new Window(this, view);
window = new Window(this, view, trackWindowState);

// High DPI support
var scaleSupport = view as IPlugViewContentScaleSupport;
Expand All @@ -35,23 +55,14 @@ public void ShowUI()
scaleSupport?.setContentScaleFactor(e.DeviceDpiNew / 96f);
};

bounds = boundsPin.Value?.Value ?? default;
if (!bounds.IsEmpty)
try
{
window.StartPosition = FormStartPosition.Manual;
SetWindowBounds(bounds);
var plugViewSize = view.getSize();
window.ClientSize = new Size(plugViewSize.Width, plugViewSize.Height);
}
else
catch (Exception e)
{
try
{
var plugViewSize = view.getSize();
window.ClientSize = new Size(plugViewSize.Width, plugViewSize.Height);
}
catch (Exception e)
{
logger.LogError(e, "Failed to get initial view size");
}
logger.LogError(e, "Failed to get initial view size");
}

try
Expand All @@ -68,34 +79,42 @@ public void ShowUI()
}
}

if (bounds.HasValue)
{
var b = bounds.Value;
window.StartPosition = FormStartPosition.Manual;
window.Location = new Point((int)b.X, (int)b.Y);
if (window.View.canResize() && b.Width > 0 && b.Height > 0)
window.Size = new Size((int)b.Width, (int)b.Height);
}

if (formWindowState.HasValue)
window.WindowState = formWindowState.Value;

window.Visible = true;
window.Activate();

if (formWindowState != FormWindowState.Minimized)
window.Activate();
}

/// <summary>
/// Hides the UI of the plugin.
/// </summary>
public void HideUI()
{
window?.Close();
window?.Dispose();
window = null;
}

void SaveCurrentWindowBounds()
void SaveWindowState(WindowState windowState)
{
if (window is null || window.IsDisposed || boundsPin.Value is null)
return;
// Save in a field to ensure that an upcoming call to ShowUI will use the previous bounds
this.windowState = windowState;

var position = window.DesktopLocation;
var bounds = new Stride.Core.Mathematics.RectangleF(position.X, position.Y, window.ClientSize.Width, window.ClientSize.Height);
SaveToChannelOrPin(boundsPin.Value, IDevSession.Current?.CurrentSolution, BoundsInputPinName, bounds)?.Confirm(JustWriteToThePin);
}

private void SetWindowBounds(Stride.Core.Mathematics.RectangleF bounds)
{
if (window is null || window.IsDisposed)
if (windowStatePin.Value is null)
return;

window.Location = new Point((int)bounds.X, (int)bounds.Y);
window.ClientSize = new Size((int)bounds.Width, (int)bounds.Height);
SaveToChannelOrPin(windowStatePin.Value, IDevSession.Current?.CurrentSolution, WindowStatePinName, windowState)?.Confirm(JustWriteToThePin);
}

[GeneratedComClass]
Expand All @@ -105,26 +124,44 @@ sealed partial class Window : Form, IPlugFrame
private readonly IPlugView view;
private readonly IPlugViewContentScaleSupport? scaleSupport;
private readonly SingleAssignmentDisposable boundsSubscription = new();
private readonly bool trackWindowState;

public Window(EffectHost effectHost, IPlugView view)
public Window(EffectHost effectHost, IPlugView view, bool trackWindowState)
{
this.effectHost = effectHost;
this.view = view;
this.trackWindowState = trackWindowState;
this.scaleSupport = view as IPlugViewContentScaleSupport;
}

public IPlugView View => view;

public bool IsTracking => trackWindowState;

public WindowState GetWindowState()
{
var r = WindowState == FormWindowState.Normal ? new Rectangle(Location, Size) : RestoreBounds;
return new WindowState(WindowState.ToWindowVisibility(), new RectangleF(r.X, r.Y, r.Width, r.Height));
}

protected override void OnHandleCreated(EventArgs e)
{
view.setFrame(this);
view.attached(Handle, "HWND");
scaleSupport?.setContentScaleFactor(DeviceDpi / 96f);

boundsSubscription.Disposable = Observable.Merge(
Observable.FromEventPattern(this, nameof(ClientSizeChanged)),
Observable.FromEventPattern(this, nameof(LocationChanged)))
.Throttle(TimeSpan.FromSeconds(0.25))
.ObserveOn(SynchronizationContext.Current!)
.Subscribe(_ => effectHost.SaveCurrentWindowBounds());
if (trackWindowState)
{
var windowState = GetWindowState();
effectHost.SaveWindowState(windowState);

boundsSubscription.Disposable = Observable.Merge(
Observable.FromEventPattern(this, nameof(ClientSizeChanged)),
Observable.FromEventPattern(this, nameof(LocationChanged)))
.Throttle(TimeSpan.FromSeconds(0.25))
.ObserveOn(SynchronizationContext.Current!)
.Subscribe(_ => effectHost.SaveWindowState(GetWindowState()));
}

base.OnHandleCreated(e);
}
Expand All @@ -138,6 +175,17 @@ protected override void OnHandleDestroyed(EventArgs e)
base.OnHandleDestroyed(e);
}

protected override void OnClosed(EventArgs e)
{
if (trackWindowState)
{
var windowState = GetWindowState();
effectHost.SaveWindowState(windowState with { Visibility = WindowVisibility.Hidden });
}

base.OnClosed(e);
}

protected override void OnDpiChanged(DpiChangedEventArgs e)
{
scaleSupport?.setContentScaleFactor(e.DeviceDpiNew / 96f);
Expand All @@ -147,8 +195,10 @@ protected override void OnDpiChanged(DpiChangedEventArgs e)

protected override void OnClientSizeChanged(EventArgs e)
{
var r = ClientRectangle;
view.onSize(new ViewRect() { Left = r.Left, Right = r.Right, Top = r.Top, Bottom = r.Bottom });
// Client size is empty when the window is minimized. Don't send that to the view, as it breaks some.
var clientSize = ClientSize;
if (!clientSize.IsEmpty)
view.onSize(new ViewRect() { Left = 0, Right = clientSize.Width, Top = 0, Bottom = clientSize.Height });

base.OnClientSizeChanged(e);
}
Expand Down
41 changes: 25 additions & 16 deletions src/EffectHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using VL.Audio.VST.Internal;
using VL.Core;
using VL.Core.Commands;
using VL.Core.CompilerServices;
using VL.Core.Reactive;
using VL.Lang.PublicAPI;
using VL.Lib.Collections;
Expand All @@ -21,15 +22,14 @@

namespace VL.Audio.VST;

using StatePin = Pin<IChannel<PluginState>>;
using AudioPin = Pin<IReadOnlyList<AudioSignal>>;
using IComponent = VST3.IComponent;

[GeneratedComClass]
[Smell(SymbolSmell.Advanced)]
public partial class EffectHost : FactoryBasedVLNode, IVLNode, IHasCommands, IHasLearnMode, IComponentHandler, IComponentHandler2, IDisposable
{
internal const string StateInputPinName = "State";
internal const string BoundsInputPinName = "Bounds";
internal const string WindowStatePinName = "Window State";
private const Model.SolutionUpdateKind JustWriteToThePin = Model.SolutionUpdateKind.Default & ~Model.SolutionUpdateKind.AffectCompilation & ~Model.SolutionUpdateKind.AddToHistory;

private static readonly ParameterChanges s_noChanges = new();
Expand Down Expand Up @@ -58,16 +58,16 @@ public partial class EffectHost : FactoryBasedVLNode, IVLNode, IHasCommands, IHa
private readonly Subject<Event> outputEvents = new();

private readonly IVLPin[] inputs, outputs;
private readonly Pin<IChannel<RectangleF>> boundsPin;
private readonly Pin<IChannel<WindowState>> windowStatePin;
private readonly Pin<IObservable<IMidiMessage>> midiInputPin, midiOutputPin;
private readonly Pin<Dictionary<string, object>> parametersPin;
private readonly Pin<bool> applyPin;
private readonly IChannel<bool> learnMode = Channel.Create(false);

private PluginState? state;
private bool stateIsBeingSet;
private RectangleF bounds;
private AudioPin audioInputPin, audioOutputPin;
private WindowState? windowState;
private Pin<IReadOnlyList<AudioSignal>> audioInputPin, audioOutputPin;
private IObservable<IMidiMessage>? midiInput;
private readonly Dictionary<string, object> inputValues = new();
private bool apply;
Expand All @@ -86,7 +86,7 @@ public partial class EffectHost : FactoryBasedVLNode, IVLNode, IHasCommands, IHa
private ImmutableArray<BusInfo> audioInputBusses, audioOutputBusses, eventInputBusses, eventOutputBusses;
private readonly ProcessSetup processSetup;
private readonly AudioOutput audioOutput;
private readonly StatePin statePin;
private readonly Pin<IChannel<PluginState>> statePin;

internal EffectHost(NodeContext nodeContext, EffectNodeInfo info) : base(nodeContext)
{
Expand Down Expand Up @@ -137,16 +137,15 @@ internal EffectHost(NodeContext nodeContext, EffectNodeInfo info) : base(nodeCon

var i = 0; var o = 0;

inputs[i] = statePin = new StatePin();
i++;
inputs[i++] = boundsPin = new Pin<IChannel<RectangleF>>();
inputs[i++] = audioInputPin = new AudioPin();
inputs[i++] = statePin = new Pin<IChannel<PluginState>>();
inputs[i++] = windowStatePin = new Pin<IChannel<WindowState>>();
inputs[i++] = audioInputPin = new Pin<IReadOnlyList<AudioSignal>>();
inputs[i++] = midiInputPin = new Pin<IObservable<IMidiMessage>>();
inputs[i++] = parametersPin = new Pin<Dictionary<string, object>>();
inputs[i++] = applyPin = new Pin<bool>();

outputs[o++] = new Pin<EffectHost>() { Value = this };
outputs[o++] = audioOutputPin = new AudioPin();
outputs[o++] = audioOutputPin = new Pin<IReadOnlyList<AudioSignal>>();
outputs[o++] = midiOutputPin = new Pin<IObservable<IMidiMessage>>()
{
Value = outputEvents.ObserveOn(Scheduler.Default).SelectMany(e => TryTranslateToMidi(in e, out var m) ? new[] { m } : [])
Expand Down Expand Up @@ -279,10 +278,12 @@ void IVLNode.Update()
}
}

if (Acknowledge(ref bounds, boundsPin.Value?.Value ?? RectangleF.Empty))
if (Acknowledge(ref windowState, windowStatePin.Value?.Value))
{
if (!bounds.IsEmpty)
SetWindowBounds(bounds);
if (windowState is null || windowState.IsVisible)
ShowUI(windowState?.Bounds, windowState?.Visibility.ToFormWindowState(), trackWindowState: true);
else
HideUI();
}

if (Acknowledge(ref apply, applyPin.Value))
Expand Down Expand Up @@ -460,7 +461,15 @@ void IComponentHandler2.finishGroupEdit()
{
get
{
yield return ("Show UI", Command.Create(ShowUI).ExecuteOn(AppHost.SynchronizationContext));
yield return ("Show UI", Command.Create(
() =>
{
var formWindowState = System.Windows.Forms.FormWindowState.Normal;
if (windowState != null && windowState.Visibility >= WindowVisibility.Normal)
formWindowState = windowState.Visibility.ToFormWindowState();
ShowUI(windowState?.Bounds, formWindowState, trackWindowState: true);
})
.ExecuteOn(AppHost.SynchronizationContext));
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Internal/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ IVLNodeDescription GetNodeDescription(IVLNodeDescriptionFactory nodeDescriptionF
var inputs = new List<IVLPinDescription>()
{
new PinDescription(EffectHost.StateInputPinName, typeof(IChannel<PluginState>)) { IsVisible = false },
new PinDescription(EffectHost.BoundsInputPinName, typeof(IChannel<RectangleF>)) { IsVisible = false },
new PinDescription(EffectHost.WindowStatePinName, typeof(IChannel<WindowState>)) { IsVisible = false },
new PinDescription("Audio In", typeof(IEnumerable<AudioSignal>)),
new PinDescription("Midi In", typeof(IObservable<IMidiMessage>)),
new PinDescription("Parameters", typeof(Dictionary<string, object>)) { PinGroupKind = PinGroupKind.Dictionary },
Expand Down
2 changes: 2 additions & 0 deletions src/PluginState.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using VL.Core;
using VL.Core.CompilerServices;
using VST3;
using VST3.Hosting;

namespace VL.Audio.VST;

[Smell(SymbolSmell.Advanced)]
public sealed record PluginState(Guid Id, ImmutableArray<byte> Component, ImmutableArray<byte> Controller)
{
public static readonly PluginState Default = new PluginState(default, ImmutableArray<byte>.Empty, ImmutableArray<byte>.Empty);
Expand Down
5 changes: 5 additions & 0 deletions src/VL.Audio.VST.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
<OutputPath>..\lib</OutputPath>
<PackageRepositories>$([System.IO.Path]::GetFullPath($(MsBuildThisFileDirectory)..\..));$([System.IO.Path]::GetFullPath($(MsBuildThisFileDirectory)..\..\VL.Audio))</PackageRepositories>
<RestoreAdditionalProjectSources>https://teamcity.vvvv.org/guestAuth/app/nuget/feed/_Root/default/v3/index.json</RestoreAdditionalProjectSources>

<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand All @@ -23,6 +26,8 @@
<PackageReference Include="VL.Audio" Version="1.9.3" />
<PackageReference Include="VL.Core.Commands" Version="2024.6.7-0282-gb45ecfaff4" />
<PackageReference Include="VL.CoreLib" Version="2024.6.7-0282-gb45ecfaff4" />
<!-- TODO: Needed to place symbols in Advanced category - should be moved to core -->
<PackageReference Include="VL.AppServices" Version="2024.6.7-0282-gb45ecfaff4" />
<PackageReference Include="VL.IO.Midi" Version="1.1.1" />
</ItemGroup>

Expand Down
Loading

0 comments on commit 5162951

Please sign in to comment.