Raw APIs (Source Extract)

This page includes exact source text for developer-facing APIs.

src/RetroWaveLab.Core.Abstractions/PluginContracts.cs


namespace RetroWaveLab.Core.Abstractions;

public interface IPlugin
{
    string Id { get; }
    string DisplayName { get; }
    Version Version { get; }
    void OnLoad(IHostContext context);
    void OnUnload();
}

public interface IProvidesDockPanes
{
    IEnumerable<DockPaneRegistration> GetDockPanes();
}

public interface IProvidesMenuContributions
{
    IEnumerable<MenuContribution> GetMenuContributions();
}

public interface IAudioEffectProcessor
{
    string EffectId { get; }
    string EffectName { get; }
    void Process(SampleBuffer sampleBuffer, SampleControllerState state);
}

public interface IAudioAnalyzer
{
    string AnalyzerId { get; }
    void Attach(IAudioSystemBus audioSystemBus);
    void Detach();
}

src/RetroWaveLab.Core.Abstractions/HostContext.cs


namespace RetroWaveLab.Core.Abstractions;

public interface IHostContext
{
    ISampleControllerBus SampleControllerBus { get; }
    IWaveHoverEventBus WaveHoverEventBus { get; }
    IAudioSystemBus AudioSystemBus { get; }
    ITransportOptionsBus TransportOptionsBus { get; }
    ISelectionEventBus SelectionEventBus { get; }
    IDocumentService DocumentService { get; }
    IPresetStore PresetStore { get; }
    ICodecRegistry CodecRegistry { get; }
    IWindowManagerApi WindowManager { get; }
}

public interface IWindowManagerApi
{
    void AddMenuContribution(int sort, string menuPath, Action<MenuCommandContext> callback, Func<bool>? isEnabled = null);
    void AddContextMenuContribution(string surfaceId, int sort, string menuPath, Action<MenuCommandContext> callback, Func<bool>? isEnabled = null);
    IReadOnlyList<MenuContribution> GetContextMenuContributions(string surfaceId);
    void AddDockPane(DockPaneRegistration pane);
    void SetPaneVisible(string paneId, bool visible);
    bool IsPaneVisible(string paneId);
    bool IsPaneFloating(string paneId);
}

src/RetroWaveLab.Core.Abstractions/MenuContribution.cs


namespace RetroWaveLab.Core.Abstractions;

public sealed record MenuContribution(
    string MenuPath,
    Action<MenuCommandContext> Execute,
    int Sort,
    Func<bool>? IsEnabled = null);

public sealed class MenuCommandContext
{
    public required ISampleControllerBus SampleControllerBus { get; init; }
    public required IAudioSystemBus AudioSystemBus { get; init; }
    public required ITransportOptionsBus TransportOptionsBus { get; init; }
    public required IWindowManagerApi WindowManager { get; init; }
}

src/RetroWaveLab.Core.Abstractions/DockPaneRegistration.cs


namespace RetroWaveLab.Core.Abstractions;

public enum DockPlacement
{
    Left,
    Right,
    Top,
    Bottom,
    Center
}

public sealed record DockPaneRegistration(
    string PaneId,
    string Title,
    DockPlacement DefaultPlacement,
    Func<object> CreateView,
    bool AllowVerticalNeighbour = true,
    bool AllowHorizontalNeighbour = true,
    bool CanUndock = true);

src/RetroWaveLab.Core.Abstractions/SampleController.cs


namespace RetroWaveLab.Core.Abstractions;

public readonly record struct SampleControllerState(
    int SampleRate,
    int Channels,
    int ViewStart,
    int ViewLength,
    int SelectionStart,
    int SelectionEnd,
    int Cursor,
    int FocusChannel = -1);

public sealed class SampleBuffer
{
    public SampleBuffer(float[] interleavedSamples, int channels, int sampleRate, int bitDepth = 32)
    {
        InterleavedSamples = interleavedSamples;
        Channels = channels;
        SampleRate = sampleRate;
        BitDepth = bitDepth;
    }

    public float[] InterleavedSamples { get; }
    public int Channels { get; }
    public int SampleRate { get; }
    public int BitDepth { get; }
}

public interface ISampleControllerBus
{
    SampleControllerState State { get; }
    SampleBuffer? Buffer { get; }

    event EventHandler<SampleControllerState>? StateChanged;
    event EventHandler? BufferChanged;

    void UpdateState(SampleControllerState state);
    void UpdateBuffer(SampleBuffer sampleBuffer);
}

src/RetroWaveLab.Core.Abstractions/Documents.cs


using System.Runtime.CompilerServices;

namespace RetroWaveLab.Core.Abstractions;

public sealed record DocumentModel(
    string DocumentId,
    string FilePath,
    string DisplayName,
    SampleBuffer Buffer,
    DateTimeOffset LoadedAtUtc);

public interface IDocumentService
{
    IReadOnlyList<DocumentModel> Documents { get; }
    DocumentModel? ActiveDocument { get; }

    event EventHandler<DocumentModel>? DocumentOpened;
    event EventHandler<DocumentModel>? DocumentClosed;
    event EventHandler<DocumentModel>? DocumentSaved;
    event EventHandler<DocumentModel?>? ActiveDocumentChanged;
    string? LastBufferEditAction { get; }
    long LastBufferEditRevision { get; }

    DocumentModel Open(string filePath);
    void Close(string documentId);
    void CloseMany(IReadOnlyList<string> documentIds);
    void CloseAll();
    DocumentModel SaveActive();
    DocumentModel SaveActiveAs(string filePath);
    DocumentModel SaveActiveCopyAs(string filePath);
    void SetActive(string documentId);
    void UpdateActiveBuffer(
        SampleBuffer buffer,
        string? action = null,
        [CallerMemberName] string callerMember = "",
        [CallerFilePath] string callerFile = "");
    DocumentModel CreateFromBuffer(SampleBuffer buffer, string baseDisplayName);
    SampleBuffer CopyRangeToBuffer(int startFrame, int endFrameExclusive);
    SampleBuffer CopyEffectiveRangeToBuffer(int startFrame, int endFrameExclusive);
    SampleBuffer CopyRangeToBuffer(int startFrame, int endFrameExclusive, int focusChannel);
    SampleBuffer ReplaceActiveRange(
        int startFrame,
        int endFrameExclusive,
        SampleBuffer replacement,
        string? action = null,
        [CallerMemberName] string callerMember = "",
        [CallerFilePath] string callerFile = "");
    SampleBuffer ReplaceActiveEffectiveRange(
        int startFrame,
        int endFrameExclusive,
        SampleBuffer replacement,
        string? action = null,
        [CallerMemberName] string callerMember = "",
        [CallerFilePath] string callerFile = "");
    SampleBuffer ReplaceActiveChannelRange(
        int startFrame,
        int endFrameExclusive,
        int targetChannel,
        SampleBuffer replacementMono,
        string? action = null,
        [CallerMemberName] string callerMember = "",
        [CallerFilePath] string callerFile = "");
    IEditPreviewSession BeginPreview(int startFrame, int endFrameExclusive);
}

public interface IEditPreviewSession : IDisposable
{
    int StartFrame { get; }
    int EndFrameExclusive { get; }
    SampleBuffer OriginalSelection { get; }

    void ApplyPreview(SampleBuffer previewSelection);
    void Commit();
    void Cancel();
}

public interface ICodecRegistry
{
    IReadOnlyList<IFileTypeCodec> Codecs { get; }
    void Register(IFileTypeCodec codec);
    IFileTypeCodec ResolveForRead(string filePath);
    IFileTypeCodec ResolveForWrite(string filePath);
}

public interface IFileTypeCodec
{
    string CodecId { get; }
    string DisplayName { get; }
    IReadOnlyList<string> Extensions { get; }

    SampleBuffer Read(string filePath);
    void Write(string filePath, SampleBuffer buffer);
}

src/RetroWaveLab.Core.Abstractions/AudioSystem.cs


namespace RetroWaveLab.Core.Abstractions;

public readonly record struct MeterFrame(double LeftPeak, double RightPeak);

public interface IPlaybackSampleProcessor
{
    bool TryProcess(float[] interleavedBuffer, int offset, int sampleCount, int channels, int sampleRate);
}

public enum PlaybackCommandType
{
    Play,
    Pause,
    Stop,
    StartRecording,
    StopRecording,
    TogglePlayPause,
    PlayBuffer,
    SeekRelative,
    SetPlaybackMotion,
    ReloadOutputDevice
}

public readonly record struct PlaybackCommand(
    PlaybackCommandType Type,
    SampleBuffer? Buffer = null,
    int StartFrame = 0,
    int EndFrameExclusive = 0,
    bool SuppressUiEvents = false,
    bool Loop = false,
    bool UpdateInPlace = false,
    string? PreviewOwnerId = null,
    IPlaybackSampleProcessor? SampleProcessor = null,
    int SeekDeltaFrames = 0,
    int MotionDirection = 1,
    float MotionSpeedFactor = 1f,
    string? OutputDeviceId = null);
public readonly record struct PlaybackState(bool IsPlaying, int CurrentFrame);
public readonly record struct RecordingState(bool IsRecording, long CapturedFrames);

public interface IAudioSystemBus
{
    event EventHandler<MeterFrame>? MeterFrameAvailable;
    event EventHandler<PlaybackCommand>? PlaybackCommandRequested;
    event EventHandler<PlaybackState>? PlaybackStateChanged;
    event EventHandler<RecordingState>? RecordingStateChanged;

    void PublishMeterFrame(MeterFrame meterFrame);
    void RequestPlayback(PlaybackCommand command);
    void PublishPlaybackState(PlaybackState state);
    void PublishRecordingState(RecordingState state);
}

src/RetroWaveLab.Core.Abstractions/SelectionEvents.cs


namespace RetroWaveLab.Core.Abstractions;

public enum SelectionChangePhase
{
    Started,
    Dragging,
    Finished,
    Programmatic
}

public readonly record struct SelectionChange(
    int Start,
    int EndExclusive,
    int Cursor,
    SelectionChangePhase Phase);

public interface ISelectionEventBus
{
    event EventHandler<SelectionChange>? SelectionChanged;
    void PublishSelectionChanged(SelectionChange change);
}

src/RetroWaveLab.Core.Abstractions/TransportOptions.cs


namespace RetroWaveLab.Core.Abstractions;

public enum TransportPlayScope
{
    Smart,
    Selection,
    View,
    EntireWave
}

public readonly record struct TransportOptionsState(
    TransportPlayScope PlayScope,
    bool LoopPlayback,
    bool FollowPlayback,
    bool CenterPlayhead,
    bool StartFromCursor);

public interface ITransportOptionsBus
{
    TransportOptionsState State { get; }
    event EventHandler<TransportOptionsState>? StateChanged;
    void UpdateState(TransportOptionsState state);
}

src/RetroWaveLab.Core.Abstractions/WaveHoverEvents.cs


namespace RetroWaveLab.Core.Abstractions;

public readonly record struct WaveHoverInfo(
    bool HasValue,
    int Channel,
    int Frame,
    float SampleValue,
    double Decibels);

public interface IWaveHoverEventBus
{
    event EventHandler<WaveHoverInfo>? HoverChanged;
    void PublishHoverChanged(WaveHoverInfo info);
}

src/RetroWaveLab.Core.Abstractions/Presets.cs


namespace RetroWaveLab.Core.Abstractions;

public sealed record PresetListEntry(string Name);

public interface IPresetStore
{
    IReadOnlyList<PresetListEntry> GetPresetList(string pluginName);
    bool PresetExists(string pluginName, string presetName);
    string? LoadPreset(string pluginName, string presetName);
    void SavePreset(string pluginName, string presetName, string jsonObject);
    bool DeletePreset(string pluginName, string presetName);
    string? LastUsed(string pluginName);
}

src/RetroWaveLab.UI.Graphing/GraphEditorControlBase.cs


using System.Drawing.Drawing2D;

namespace RetroWaveLab.UI.Graphing;

public abstract class GraphEditorControlBase : Control
{
    protected readonly record struct GraphPoint(float X, float Y);

    private RectangleF _plotRect;
    private Point _mousePoint = Point.Empty;
    private bool _mouseInPlot;
    private readonly List<GraphPoint> _points = [];
    private bool _hasAxisRanges;
    private float _axisXMin;
    private float _axisXMax = 1f;
    private float _axisYMin;
    private float _axisYMax = 1f;
    private string _axisXUnit = string.Empty;
    private string _axisYUnit = string.Empty;
    private int _dragPointIndex = -1;
    private bool _pointEditedDuringInteraction;
    private int? _xLabelTickCount;
    private Label? _boundReadoutLabel;
    private Func<float, float, string>? _boundReadoutFormatter;
    private string _boundReadoutEmptyText = string.Empty;

    public sealed class GraphMouseReadoutEventArgs : EventArgs
    {
        public static readonly GraphMouseReadoutEventArgs NoReadout = new(false, 0f, 0f);

        public GraphMouseReadoutEventArgs(bool hasReadout, float xValue, float yValue)
        {
            HasReadout = hasReadout;
            XValue = xValue;
            YValue = yValue;
        }

        public bool HasReadout { get; }
        public float XValue { get; }
        public float YValue { get; }
    }

    protected GraphEditorControlBase()
    {
        DoubleBuffered = true;
        SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);
        BackColor = Color.FromArgb(8, 12, 18);
        PlotBackgroundColour = Color.FromArgb(8, 12, 18);
        ForeColor = Color.FromArgb(96, 238, 196);
        Cursor = Cursors.Cross;
    }

    protected RectangleF PlotRect
    {
        get => _plotRect;
        set => _plotRect = value;
    }

    protected bool MouseInPlot => _mouseInPlot;
    protected Point MousePoint => _mousePoint;
    protected IReadOnlyList<GraphPoint> Points => _points;
    protected int PointCount => _points.Count;

    public bool ShowCrosshair { get; set; } = true;
    public Color PlotBackgroundColour { get; set; }
    public Color AxisColour { get; set; } = Color.FromArgb(120, 140, 160);
    public Color GridColour { get; set; } = Color.FromArgb(0, 120, 0);
    public Color TickTextColour { get; set; } = Color.FromArgb(184, 200, 214);
    public Color CrosshairColour { get; set; } = Color.FromArgb(80, 180, 200, 200);
    public bool AllowPointAdd { get; set; } = true;
    public bool AllowPointDelete { get; set; } = true;
    public bool AllowPointDrag { get; set; } = true;
    public bool DeletePointOnDragOutsideControl { get; set; } = true;
    public float PointHitRadiusPx { get; set; } = 5f;
    public bool EnableDefaultPointEditor { get; set; } = true;
    public bool EnableDefaultPointInteraction { get; set; } = true;
    public bool LockEndpointXOnDrag { get; set; } = true;
    public bool LockEndpointXOnEdit { get; set; } = true;
    public bool ClampInteriorXForNonEndpoints { get; set; } = true;
    public int? XLabelTickCount
    {
        get => _xLabelTickCount;
        set
        {
            if (_xLabelTickCount == value)
            {
                return;
            }

            _xLabelTickCount = value;
            Invalidate();
        }
    }
    public string PointEditXLabel { get; set; } = "Input Signal Level";
    public string PointEditYLabel { get; set; } = "Output Signal Level";
    public int PointEditXDecimalPlaces { get; set; } = 3;
    public int PointEditYDecimalPlaces { get; set; } = 3;

    public event EventHandler? PointsChanged;
    public event EventHandler? PointsEditCommitted;
    public event EventHandler<GraphMouseReadoutEventArgs>? MouseReadout;

    protected static readonly Font TickFont = new("Segoe UI", 7.5f);

    protected readonly record struct PointEditRequest(
        string XLabel,
        string XUnit,
        decimal XValue,
        decimal XMinimum,
        decimal XMaximum,
        bool LockX,
        string YLabel,
        string YUnit,
        decimal YValue,
        decimal YMinimum,
        decimal YMaximum,
        int XDecimalPlaces = 2,
        int YDecimalPlaces = 2,
        string Title = "Edit Point");

    protected readonly record struct PointEditResult(decimal XValue, decimal YValue);

    public void BindReadoutLabel(Label? label, Func<float, float, string>? formatter = null, string emptyText = "")
    {
        _boundReadoutLabel = label;
        _boundReadoutFormatter = formatter;
        _boundReadoutEmptyText = emptyText ?? string.Empty;
        if (_boundReadoutLabel is not null)
        {
            _boundReadoutLabel.Text = _boundReadoutEmptyText;
        }
    }

    protected override void OnResize(EventArgs e)
    {
        base.OnResize(e);
        Invalidate();
    }

    protected override void OnMouseDown(MouseEventArgs e)
    {
        base.OnMouseDown(e);
        if (!EnableDefaultPointInteraction || e.Button != MouseButtons.Left)
        {
            return;
        }

        _pointEditedDuringInteraction = false;
        var hit = HitTestPointIndex(e.Location);
        _dragPointIndex = hit;
        if (_dragPointIndex < 0 && PlotRect.Contains(e.Location) && AllowPointAdd)
        {
            var xNorm = ScreenToNormalizedX(e.X);
            if (ClampInteriorXForNonEndpoints)
            {
                xNorm = ClampInteriorX(xNorm);
            }

            AddPoint(new GraphPoint(xNorm, ScreenToNormalizedY(e.Y)));
            _dragPointIndex = PointCount - 1;
            _pointEditedDuringInteraction = true;
            Invalidate();
        }
    }

    protected override void OnMouseMove(MouseEventArgs e)
    {
        base.OnMouseMove(e);
        UpdateCrosshairTracking(e.Location);
        PublishReadout(e.Location);

        if (!EnableDefaultPointInteraction)
        {
            return;
        }

        var hit = HitTestPointIndex(e.Location);
        var desiredCursor = hit >= 0 ? Cursors.Hand : Cursors.Cross;
        if (Cursor != desiredCursor)
        {
            Cursor = desiredCursor;
        }

        if ((e.Button & MouseButtons.Left) == 0 && _dragPointIndex >= 0)
        {
            if (_pointEditedDuringInteraction)
            {
                RaisePointsEditCommitted();
                _pointEditedDuringInteraction = false;
            }
            _dragPointIndex = -1;
        }

        if (_dragPointIndex < 0 || (e.Button & MouseButtons.Left) == 0 || !AllowPointDrag)
        {
            return;
        }

        if (e.X < 0 || e.Y < 0 || e.X > Width || e.Y > Height)
        {
            if (DeletePointOnDragOutsideControl && AllowPointDelete && !IsEndpointIndex(_dragPointIndex))
            {
                if (RemovePointAt(_dragPointIndex))
                {
                    _pointEditedDuringInteraction = true;
                }
            }

            if (_pointEditedDuringInteraction)
            {
                RaisePointsEditCommitted();
                _pointEditedDuringInteraction = false;
            }
            _dragPointIndex = -1;
            Invalidate();
            return;
        }

        if (_dragPointIndex >= PointCount)
        {
            _dragPointIndex = -1;
            return;
        }

        var current = GetPointAt(_dragPointIndex);
        var lockX = LockEndpointXOnDrag && IsEndpointIndex(_dragPointIndex);
        var xNorm = lockX ? current.X : ScreenToNormalizedX(e.X);
        if (!lockX && ClampInteriorXForNonEndpoints)
        {
            xNorm = ClampInteriorX(xNorm);
        }

        var yNorm = ScreenToNormalizedY(e.Y);
        if (UpdatePointAt(_dragPointIndex, new GraphPoint(xNorm, yNorm)))
        {
            _pointEditedDuringInteraction = true;
        }
        Invalidate();
    }

    protected override void OnMouseUp(MouseEventArgs e)
    {
        base.OnMouseUp(e);
        if (e.Button == MouseButtons.Left)
        {
            if (_pointEditedDuringInteraction)
            {
                RaisePointsEditCommitted();
                _pointEditedDuringInteraction = false;
            }
            _dragPointIndex = -1;
            return;
        }

        if (e.Button != MouseButtons.Right || !EnableDefaultPointEditor || !_hasAxisRanges)
        {
            return;
        }

        var index = HitTestPointIndex(e.Location);
        if (index < 0 || index >= PointCount)
        {
            return;
        }

        var point = GetPointAt(index);
        var xValue = _axisXMin + (Clamp01(point.X) * (_axisXMax - _axisXMin));
        var yValue = _axisYMin + (Clamp01(point.Y) * (_axisYMax - _axisYMin));
        var lockX = LockEndpointXOnEdit && IsEndpointIndex(index);

        ShowPointContextMenu(
            e.Location,
            new PointEditRequest(
                XLabel: PointEditXLabel,
                XUnit: _axisXUnit,
                XValue: (decimal)xValue,
                XMinimum: (decimal)_axisXMin,
                XMaximum: (decimal)_axisXMax,
                LockX: lockX,
                YLabel: PointEditYLabel,
                YUnit: _axisYUnit,
                YValue: (decimal)yValue,
                YMinimum: (decimal)_axisYMin,
                YMaximum: (decimal)_axisYMax,
                XDecimalPlaces: PointEditXDecimalPlaces,
                YDecimalPlaces: PointEditYDecimalPlaces,
                Title: "Edit Point"),
            result =>
            {
                var xNorm = lockX
                    ? point.X
                    : Clamp01(((float)result.XValue - _axisXMin) / Math.Max(0.000001f, _axisXMax - _axisXMin));
                var yNorm = Clamp01(((float)result.YValue - _axisYMin) / Math.Max(0.000001f, _axisYMax - _axisYMin));
                if (UpdatePointAt(index, new GraphPoint(xNorm, yNorm)))
                {
                    RaisePointsEditCommitted();
                }
                Invalidate();
            });
    }

    protected override void OnMouseLeave(EventArgs e)
    {
        base.OnMouseLeave(e);
        ClearCrosshairTracking();
        if (_boundReadoutLabel is not null)
        {
            _boundReadoutLabel.Text = _boundReadoutEmptyText;
        }

        MouseReadout?.Invoke(this, GraphMouseReadoutEventArgs.NoReadout);
    }

    protected void UpdateCrosshairTracking(Point point)
    {
        var prevInPlot = _mouseInPlot;
        var prevPoint = _mousePoint;
        _mousePoint = point;
        _mouseInPlot = _plotRect.Contains(point);
        if (ShowCrosshair && (prevInPlot != _mouseInPlot || prevPoint != _mousePoint))
        {
            Invalidate();
        }
    }

    protected void ClearCrosshairTracking()
    {
        if (!_mouseInPlot)
        {
            return;
        }

        _mouseInPlot = false;
        if (ShowCrosshair)
        {
            Invalidate();
        }
    }

    protected int AddPoint(GraphPoint point)
    {
        _points.Add(new GraphPoint(Clamp01(point.X), Clamp01(point.Y)));
        RaisePointsChanged();
        return _points.Count - 1;
    }

    protected bool RemovePointAt(int index)
    {
        if ((uint)index >= (uint)_points.Count)
        {
            return false;
        }

        _points.RemoveAt(index);
        RaisePointsChanged();
        return true;
    }

    protected bool UpdatePointAt(int index, GraphPoint point)
    {
        if ((uint)index >= (uint)_points.Count)
        {
            return false;
        }

        _points[index] = new GraphPoint(Clamp01(point.X), Clamp01(point.Y));
        RaisePointsChanged();
        return true;
    }

    protected GraphPoint GetPointAt(int index)
    {
        return (uint)index < (uint)_points.Count ? _points[index] : default;
    }

    protected void SetPoints(IEnumerable<GraphPoint> points, bool ensureEndpoints = false)
    {
        _points.Clear();
        if (points is not null)
        {
            _points.AddRange(points.Select(p => new GraphPoint(Clamp01(p.X), Clamp01(p.Y))));
        }

        _points.Sort(static (a, b) => a.X.CompareTo(b.X));
        if (ensureEndpoints)
        {
            EnsureEndpointAnchors();
        }

        RaisePointsChanged();
    }

    protected void SortPointsByX()
    {
        _points.Sort(static (a, b) => a.X.CompareTo(b.X));
    }

    protected bool IsEndpointIndex(int index)
    {
        if ((uint)index >= (uint)_points.Count)
        {
            return false;
        }

        var p = _points[index];
        return Math.Abs(p.X - 0f) < 0.0001f || Math.Abs(p.X - 1f) < 0.0001f;
    }

    protected int HitTestPointIndex(Point location)
    {
        var hitDistSquared = PointHitRadiusPx * PointHitRadiusPx;
        foreach (var item in _points.Select((p, i) => (Point: p, Index: i)).OrderBy(x => x.Point.X))
        {
            var x = NormalizedXToScreen(item.Point.X);
            var y = NormalizedYToScreen(item.Point.Y);
            var dx = location.X - x;
            var dy = location.Y - y;
            if ((dx * dx) + (dy * dy) <= hitDistSquared)
            {
                return item.Index;
            }
        }

        return -1;
    }

    protected float NormalizedXToScreen(float x)
    {
        return _plotRect.Left + (_plotRect.Width * Clamp01(x));
    }

    protected float NormalizedYToScreen(float y)
    {
        return _plotRect.Bottom - (_plotRect.Height * Clamp01(y));
    }

    protected float ScreenToNormalizedX(float x)
    {
        if (_plotRect.Width <= 0f)
        {
            return 0f;
        }

        return Clamp01((x - _plotRect.Left) / _plotRect.Width);
    }

    protected float ScreenToNormalizedY(float y)
    {
        if (_plotRect.Height <= 0f)
        {
            return 0f;
        }

        return Clamp01((_plotRect.Bottom - y) / _plotRect.Height);
    }

    protected static float Clamp01(float value) => Math.Clamp(value, 0f, 1f);

    protected static float ClampInteriorX(float normalizedX)
    {
        normalizedX = Clamp01(normalizedX);
        if (normalizedX <= 0f) return 0.0001f;
        if (normalizedX >= 1f) return 0.9999f;
        return normalizedX;
    }

    protected IEnumerable<(GraphPoint Point, int Index)> EnumeratePointsOrdered()
    {
        return _points.Select((p, i) => (Point: p, Index: i)).OrderBy(x => x.Point.X);
    }

    protected void DrawGraphPolylineAndPoints(Graphics g, Pen linePen, Brush pointBrush, Pen pointBorder, float pointSize = 6f)
    {
        var ordered = _points.OrderBy(p => p.X).ToArray();
        if (ordered.Length > 1)
        {
            g.DrawLines(linePen, ordered.Select(p => new PointF(NormalizedXToScreen(p.X), NormalizedYToScreen(p.Y))).ToArray());
        }

        var half = pointSize * 0.5f;
        foreach (var p in ordered)
        {
            var r = new RectangleF(NormalizedXToScreen(p.X) - half, NormalizedYToScreen(p.Y) - half, pointSize, pointSize);
            g.FillRectangle(pointBrush, r);
            g.DrawRectangle(pointBorder, r.X, r.Y, r.Width, r.Height);
        }
    }

    protected virtual void RaisePointsChanged()
    {
        PointsChanged?.Invoke(this, EventArgs.Empty);
    }

    protected virtual void RaisePointsEditCommitted()
    {
        PointsEditCommitted?.Invoke(this, EventArgs.Empty);
    }

    protected RectangleF BuildPlotRect(float leftMargin, float topMargin, float rightMargin, float bottomMargin, float minWidth = 50f, float minHeight = 50f)
    {
        return new RectangleF(
            leftMargin,
            topMargin,
            Math.Max(minWidth, Width - leftMargin - rightMargin),
            Math.Max(minHeight, Height - topMargin - bottomMargin));
    }

    protected float ValueToX(float value, float min, float max)
    {
        if (_plotRect.Width <= 0f || Math.Abs(max - min) < 0.000001f)
        {
            return _plotRect.Left;
        }

        var t = (Math.Clamp(value, min, max) - min) / (max - min);
        return _plotRect.Left + (_plotRect.Width * t);
    }

    protected float ValueToY(float value, float min, float max)
    {
        if (_plotRect.Height <= 0f || Math.Abs(max - min) < 0.000001f)
        {
            return _plotRect.Bottom;
        }

        var t = (Math.Clamp(value, min, max) - min) / (max - min);
        return _plotRect.Bottom - (_plotRect.Height * t);
    }

    protected float XToValue(float x, float min, float max)
    {
        if (_plotRect.Width <= 0f || Math.Abs(max - min) < 0.000001f)
        {
            return min;
        }

        var t = Math.Clamp((x - _plotRect.Left) / _plotRect.Width, 0f, 1f);
        return min + (t * (max - min));
    }

    protected float YToValue(float y, float min, float max)
    {
        if (_plotRect.Height <= 0f || Math.Abs(max - min) < 0.000001f)
        {
            return min;
        }

        var t = Math.Clamp((_plotRect.Bottom - y) / _plotRect.Height, 0f, 1f);
        return min + (t * (max - min));
    }

    protected void DrawGridAndAxes(
        Graphics g,
        float xMin,
        float xMax,
        float xStep,
        float yMin,
        float yMax,
        float yStep,
        Func<float, string>? xTickFormatter,
        Func<float, string>? yTickFormatter,
        string? xUnitText = null,
        string? yUnitText = null,
        bool drawLeftYAxisLabels = true,
        bool drawBottomXAxisLabels = true,
        int? xLabelTickCount = null)
    {
        xLabelTickCount ??= XLabelTickCount;

        _hasAxisRanges = true;
        _axisXMin = xMin;
        _axisXMax = xMax;
        _axisYMin = yMin;
        _axisYMax = yMax;
        _axisXUnit = xUnitText ?? string.Empty;
        _axisYUnit = yUnitText ?? string.Empty;

        using var axisPen = new Pen(AxisColour, 1f);
        using var gridPen = new Pen(GridColour, 1f);
        using var tickBrush = new SolidBrush(TickTextColour);
        using var plotBrush = new SolidBrush(PlotBackgroundColour);

        g.FillRectangle(plotBrush, _plotRect);

        var xTickCount = 0;
        if (xLabelTickCount.HasValue)
        {
            if (xLabelTickCount.Value == 0)
            {
                var referenceWidth = Math.Max(1f, g.MeasureString("000000", TickFont).Width);
                xTickCount = Math.Max(2, (int)Math.Floor(_plotRect.Width / referenceWidth));
            }
            else
            {
                xTickCount = Math.Max(2, xLabelTickCount.Value);
            }
        }

        if (xTickCount > 1)
        {
            for (var i = 0; i < xTickCount; i++)
            {
                var t = i / (float)(xTickCount - 1);
                var xVal = xMin + ((xMax - xMin) * t);
                var x = _plotRect.Left + (_plotRect.Width * t);
                g.DrawLine(gridPen, x, _plotRect.Top, x, _plotRect.Bottom);
                if (drawBottomXAxisLabels && xTickFormatter is not null)
                {
                    DrawTickLabel(g, xTickFormatter(xVal), x, _plotRect.Bottom + 2f, horizontal: true, tickBrush);
                }
            }
        }
        else
        {
            if (xStep > 0.000001f)
            {
                for (var xVal = xMin; xVal <= xMax + (xStep * 0.25f); xVal += xStep)
                {
                    var x = ValueToX(xVal, xMin, xMax);
                    g.DrawLine(gridPen, x, _plotRect.Top, x, _plotRect.Bottom);
                }
            }

            if (drawBottomXAxisLabels && xTickFormatter is not null && xStep > 0.000001f)
            {
                for (var xVal = xMin; xVal <= xMax + (xStep * 0.25f); xVal += xStep)
                {
                    var x = ValueToX(xVal, xMin, xMax);
                    DrawTickLabel(g, xTickFormatter(xVal), x, _plotRect.Bottom + 2f, horizontal: true, tickBrush);
                }
            }
        }

        if (yStep > 0.000001f)
        {
            for (var yVal = yMin; yVal <= yMax + (yStep * 0.25f); yVal += yStep)
            {
                var y = ValueToY(yVal, yMin, yMax);
                g.DrawLine(gridPen, _plotRect.Left, y, _plotRect.Right, y);
                if (drawLeftYAxisLabels && yTickFormatter is not null)
                {
                    DrawLeftTickLabel(g, yTickFormatter(yVal), _plotRect.Left - 6f, y, tickBrush);
                }
            }
        }

        g.DrawRectangle(axisPen, _plotRect.X, _plotRect.Y, _plotRect.Width, _plotRect.Height);

        if (!string.IsNullOrWhiteSpace(xUnitText))
        {
            g.DrawString(xUnitText, TickFont, tickBrush, _plotRect.Left + 2f, _plotRect.Bottom + 2f);
        }

        if (!string.IsNullOrWhiteSpace(yUnitText))
        {
            var size = g.MeasureString(yUnitText, TickFont);
            g.DrawString(yUnitText, TickFont, tickBrush, _plotRect.Right + 2f, _plotRect.Top - (size.Height * 0.25f));
        }
    }

    protected void DrawCrosshair(Graphics g)
    {
        if (!ShowCrosshair || !_mouseInPlot)
        {
            return;
        }

        using var crossPen = new Pen(CrosshairColour, 1f) { DashStyle = DashStyle.Dot };
        var cx = Math.Clamp(_mousePoint.X, (int)_plotRect.Left, (int)_plotRect.Right);
        var cy = Math.Clamp(_mousePoint.Y, (int)_plotRect.Top, (int)_plotRect.Bottom);
        g.DrawLine(crossPen, cx, _plotRect.Top, cx, _plotRect.Bottom);
        g.DrawLine(crossPen, _plotRect.Left, cy, _plotRect.Right, cy);
    }

    protected static void DrawTickLabel(Graphics g, string text, float x, float y, bool horizontal, Brush brush)
    {
        var size = g.MeasureString(text, TickFont);
        if (horizontal)
        {
            g.DrawString(text, TickFont, brush, x - (size.Width * 0.5f), y);
        }
        else
        {
            g.DrawString(text, TickFont, brush, x - size.Width, y - (size.Height * 0.5f));
        }
    }

    protected static void DrawLeftTickLabel(Graphics g, string text, float xRight, float y, Brush brush)
    {
        var size = g.MeasureString(text, TickFont);
        g.DrawString(text, TickFont, brush, xRight - size.Width, y - (size.Height * 0.5f));
    }

    protected void ShowPointContextMenu(
        Point clientLocation,
        PointEditRequest request,
        Action<PointEditResult> onApply,
        Action? onDelete = null)
    {
        if (onApply is null)
        {
            return;
        }

        using var dlg = new GraphPointEditDialog();
        dlg.Configure(
            title: request.Title,
            xLabel: request.XLabel,
            xUnit: request.XUnit,
            xValue: request.XValue,
            xMinimum: request.XMinimum,
            xMaximum: request.XMaximum,
            xDecimalPlaces: request.XDecimalPlaces,
            lockX: request.LockX,
            yLabel: request.YLabel,
            yUnit: request.YUnit,
            yValue: request.YValue,
            yMinimum: request.YMinimum,
            yMaximum: request.YMaximum,
            yDecimalPlaces: request.YDecimalPlaces);
        var result = dlg.ShowDialog(this);
        if (result == DialogResult.OK)
        {
            onApply(new PointEditResult(dlg.XValue, dlg.YValue));
        }
    }

    private void EnsureEndpointAnchors()
    {
        if (_points.Count == 0)
        {
            _points.Add(new GraphPoint(0f, 0f));
            _points.Add(new GraphPoint(1f, 0f));
            return;
        }

        _points.Sort(static (a, b) => a.X.CompareTo(b.X));
        var first = _points[0];
        if (Math.Abs(first.X - 0f) > 0.0001f)
        {
            _points.Insert(0, new GraphPoint(0f, first.Y));
        }
        else
        {
            _points[0] = first with { X = 0f };
        }

        var last = _points[^1];
        if (Math.Abs(last.X - 1f) > 0.0001f)
        {
            _points.Add(new GraphPoint(1f, last.Y));
        }
        else
        {
            _points[^1] = last with { X = 1f };
        }
    }

    private void PublishReadout(Point location)
    {
        if (!_hasAxisRanges)
        {
            if (_boundReadoutLabel is not null)
            {
                _boundReadoutLabel.Text = _boundReadoutEmptyText;
            }
            MouseReadout?.Invoke(this, GraphMouseReadoutEventArgs.NoReadout);
            return;
        }

        if (!PlotRect.Contains(location))
        {
            if (_boundReadoutLabel is not null)
            {
                _boundReadoutLabel.Text = _boundReadoutEmptyText;
            }
            MouseReadout?.Invoke(this, GraphMouseReadoutEventArgs.NoReadout);
            return;
        }

        var xNorm = ScreenToNormalizedX(location.X);
        var yNorm = ScreenToNormalizedY(location.Y);
        var xValue = _axisXMin + (xNorm * (_axisXMax - _axisXMin));
        var yValue = _axisYMin + (yNorm * (_axisYMax - _axisYMin));

        if (_boundReadoutLabel is not null)
        {
            _boundReadoutLabel.Text = _boundReadoutFormatter is null
                ? FormatDefaultReadout(xValue, yValue)
                : _boundReadoutFormatter(xValue, yValue);
        }

        MouseReadout?.Invoke(this, new GraphMouseReadoutEventArgs(true, xValue, yValue));
    }

    private string FormatDefaultReadout(float xValue, float yValue)
    {
        var xUnit = string.IsNullOrWhiteSpace(_axisXUnit) ? string.Empty : $" {_axisXUnit}";
        var yUnit = string.IsNullOrWhiteSpace(_axisYUnit) ? string.Empty : $" {_axisYUnit}";
        return $"{xValue:0.###}{xUnit} -> {yValue:0.###}{yUnit}";
    }
}

src/RetroWaveLab.UI.Graphing/GraphPointEditDialog.cs


namespace RetroWaveLab.UI.Graphing;

internal partial class GraphPointEditDialog : Form
{
    public GraphPointEditDialog()
    {
        InitializeComponent();
        AcceptButton = _okButton;
        CancelButton = _cancelButton;
    }

    public decimal XValue => _xValue.Value;
    public decimal YValue => _yValue.Value;

    public void Configure(
        string title,
        string xLabel,
        string xUnit,
        decimal xValue,
        decimal xMinimum,
        decimal xMaximum,
        int xDecimalPlaces,
        bool lockX,
        string yLabel,
        string yUnit,
        decimal yValue,
        decimal yMinimum,
        decimal yMaximum,
        int yDecimalPlaces)
    {
        Text = string.IsNullOrWhiteSpace(title) ? "Edit Point" : title;
        _xLabel.Text = xLabel;
        _xUnitLabel.Text = xUnit;
        _yLabel.Text = yLabel;
        _yUnitLabel.Text = yUnit;

        ConfigureNumeric(_xValue, xValue, xMinimum, xMaximum, xDecimalPlaces);
        ConfigureNumeric(_yValue, yValue, yMinimum, yMaximum, yDecimalPlaces);
        _xValue.Enabled = !lockX;
    }

    private static void ConfigureNumeric(NumericUpDown box, decimal value, decimal minimum, decimal maximum, int decimalPlaces)
    {
        box.DecimalPlaces = Math.Max(0, decimalPlaces);
        box.Minimum = Math.Min(minimum, maximum);
        box.Maximum = Math.Max(minimum, maximum);
        box.Value = Math.Clamp(value, box.Minimum, box.Maximum);
    }
}

src/RetroWaveLab.UI.Graphing/GraphPointEditDialog.Designer.cs


namespace RetroWaveLab.UI.Graphing;

partial class GraphPointEditDialog
{
    private System.ComponentModel.IContainer components = null!;
    private Button _okButton = null!;
    private Button _cancelButton = null!;

    protected override void Dispose(bool disposing)
    {
        if (disposing && (components is not null))
        {
            components.Dispose();
        }

        base.Dispose(disposing);
    }

    private void InitializeComponent()
    {
        _okButton = new Button();
        _cancelButton = new Button();
        _yLabel = new Label();
        _xLabel = new Label();
        _yUnitLabel = new Label();
        _xUnitLabel = new Label();
        _yValue = new NumericUpDown();
        _xValue = new NumericUpDown();
        panel1 = new Panel();
        panel2 = new Panel();
        ((System.ComponentModel.ISupportInitialize)_yValue).BeginInit();
        ((System.ComponentModel.ISupportInitialize)_xValue).BeginInit();
        panel1.SuspendLayout();
        panel2.SuspendLayout();
        SuspendLayout();
        // 
        // _okButton
        // 
        _okButton.DialogResult = DialogResult.OK;
        _okButton.Location = new Point(94, 92);
        _okButton.Name = "_okButton";
        _okButton.Size = new Size(76, 23);
        _okButton.TabIndex = 0;
        _okButton.Text = "OK";
        _okButton.UseVisualStyleBackColor = true;
        // 
        // _cancelButton
        // 
        _cancelButton.DialogResult = DialogResult.Cancel;
        _cancelButton.Location = new Point(179, 92);
        _cancelButton.Margin = new Padding(6, 3, 3, 3);
        _cancelButton.Name = "_cancelButton";
        _cancelButton.Size = new Size(76, 23);
        _cancelButton.TabIndex = 1;
        _cancelButton.Text = "Cancel";
        _cancelButton.UseVisualStyleBackColor = true;
        // 
        // _yLabel
        // 
        _yLabel.AutoSize = true;
        _yLabel.Dock = DockStyle.Right;
        _yLabel.Location = new Point(14, 0);
        _yLabel.Name = "_yLabel";
        _yLabel.Size = new Size(106, 15);
        _yLabel.TabIndex = 7;
        _yLabel.Text = "label1 label1 label1";
        _yLabel.TextAlign = ContentAlignment.MiddleRight;
        // 
        // _xLabel
        // 
        _xLabel.AutoSize = true;
        _xLabel.Dock = DockStyle.Right;
        _xLabel.Location = new Point(14, 0);
        _xLabel.Name = "_xLabel";
        _xLabel.Size = new Size(106, 15);
        _xLabel.TabIndex = 8;
        _xLabel.Text = "label2 label2 label2";
        _xLabel.TextAlign = ContentAlignment.MiddleRight;
        // 
        // _yUnitLabel
        // 
        _yUnitLabel.AutoSize = true;
        _yUnitLabel.Location = new Point(222, 12);
        _yUnitLabel.Name = "_yUnitLabel";
        _yUnitLabel.Size = new Size(33, 15);
        _yUnitLabel.TabIndex = 9;
        _yUnitLabel.Text = "scale";
        // 
        // _xUnitLabel
        // 
        _xUnitLabel.AutoSize = true;
        _xUnitLabel.Location = new Point(222, 49);
        _xUnitLabel.Name = "_xUnitLabel";
        _xUnitLabel.Size = new Size(33, 15);
        _xUnitLabel.TabIndex = 10;
        _xUnitLabel.Text = "scale";
        // 
        // _yValue
        // 
        _yValue.DecimalPlaces = 2;
        _yValue.Location = new Point(138, 10);
        _yValue.Minimum = new decimal(new int[] { 100, 0, 0, int.MinValue });
        _yValue.Name = "_yValue";
        _yValue.Size = new Size(78, 23);
        _yValue.TabIndex = 11;
        // 
        // _xValue
        // 
        _xValue.DecimalPlaces = 2;
        _xValue.Location = new Point(138, 47);
        _xValue.Minimum = new decimal(new int[] { 100, 0, 0, int.MinValue });
        _xValue.Name = "_xValue";
        _xValue.Size = new Size(78, 23);
        _xValue.TabIndex = 12;
        // 
        // panel1
        // 
        panel1.Controls.Add(_yLabel);
        panel1.Location = new Point(12, 12);
        panel1.Name = "panel1";
        panel1.Size = new Size(120, 15);
        panel1.TabIndex = 13;
        // 
        // panel2
        // 
        panel2.Controls.Add(_xLabel);
        panel2.Location = new Point(12, 49);
        panel2.Name = "panel2";
        panel2.Size = new Size(120, 15);
        panel2.TabIndex = 14;
        // 
        // GraphPointEditDialog
        // 
        AutoScaleDimensions = new SizeF(7F, 15F);
        AutoScaleMode = AutoScaleMode.Font;
        AutoSize = true;
        ClientSize = new Size(269, 127);
        Controls.Add(_cancelButton);
        Controls.Add(_okButton);
        Controls.Add(panel2);
        Controls.Add(panel1);
        Controls.Add(_xValue);
        Controls.Add(_yValue);
        Controls.Add(_xUnitLabel);
        Controls.Add(_yUnitLabel);
        FormBorderStyle = FormBorderStyle.FixedDialog;
        MaximizeBox = false;
        MinimizeBox = false;
        Name = "GraphPointEditDialog";
        ShowInTaskbar = false;
        StartPosition = FormStartPosition.CenterParent;
        Text = "Edit Point";
        ((System.ComponentModel.ISupportInitialize)_yValue).EndInit();
        ((System.ComponentModel.ISupportInitialize)_xValue).EndInit();
        panel1.ResumeLayout(false);
        panel1.PerformLayout();
        panel2.ResumeLayout(false);
        panel2.PerformLayout();
        ResumeLayout(false);
        PerformLayout();
    }

    private Label _yLabel;
    private Label _xLabel;
    private Label _yUnitLabel;
    private Label _xUnitLabel;
    private NumericUpDown _yValue;
    private NumericUpDown _xValue;
    private Panel panel1;
    private Panel panel2;
}

Back to top