OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / Controls / ValueSlider.axaml.cs

788 lines · csharp ref:
using System;
using System.Globalization;
using System.Runtime.InteropServices;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;

namespace IFSRenderer.AvaloniaShell.Controls;

/// <summary>
/// The numeric control the whole UI is built from: <c>- value +</c> with drag-to-scrub,
/// typed entry, reset, and the keyframe diamond.
/// </summary>
/// <remarks>
/// <para>
/// A port of <c>WpfDisplay/Views/ValueSlider.xaml(.cs)</c>, which is instantiated ~40 times
/// across the Windows app and is therefore the visual texture of every panel. Behaviour is
/// matched deliberately, including the parts that are easy to miss:
/// </para>
/// <list type="bullet">
/// <item>Ctrl multiplies the increment by 10, Shift by 0.1 — on the buttons and on the drag.</item>
/// <item>The display format is <c>N0</c> when <see cref="Increment"/> is a whole number and
/// <c>N4</c> otherwise, so integers do not show ".0000".</item>
/// <item>Reset sets the value to <see cref="DefaultValue"/>, or to 0 if it is already there.
/// That is Apophysis' behaviour and the original copies it.</item>
/// <item>The diamond has three states — see <see cref="UpdateAnimatedSymbol"/>.</item>
/// </list>
/// <para>
/// The scrub WRAPS the pointer at the edge of the monitor, so it never runs out of desk — what
/// Blender calls continuous grab. Avalonia has no cursor-warp API, but X11 does
/// (<c>XWarpPointer</c>), so it is called directly. Where that is unavailable — a Wayland
/// session with no XWayland — the cursor simply travels and the scrub stops at the screen edge.
/// </para>
/// </remarks>
public partial class ValueSlider : UserControl
{
    // ------------------------------------------------------------------ cursor warp

    // DllImport rather than LibraryImport: every signature here is blittable, so the source
    // generator would buy nothing, and it demands AllowUnsafeBlocks across the whole project.
    [DllImport("libX11.so.6")]
    private static extern IntPtr XOpenDisplay(IntPtr name);

    [DllImport("libX11.so.6")]
    private static extern IntPtr XDefaultRootWindow(IntPtr display);

    [DllImport("libX11.so.6")]
    private static extern int XWarpPointer(IntPtr display, IntPtr srcWindow, IntPtr destWindow,
        int srcX, int srcY, uint srcWidth, uint srcHeight, int destX, int destY);

    [DllImport("libX11.so.6")]
    private static extern int XFlush(IntPtr display);

    private static IntPtr _display;
    private static bool _displayResolved;

    /// <summary>
    /// How the pointer is moved. Swappable so the wrap arithmetic can be tested without an X
    /// server, and so the no-warp path (Wayland, or a headless run) is exercisable too.
    /// </summary>
    internal static Func<PixelPoint, bool> PointerWarp { get; set; } = WarpPointer;

    /// <summary>
    /// Moves the system pointer to a screen position. False when no X11 display can be
    /// reached, in which case the caller must cope with a pointer that travels.
    /// </summary>
    /// <remarks>
    /// The display handle is opened once and kept: it is only ever used to warp, and closing
    /// it per drag would cost a round trip to the X server on every pointer move.
    /// </remarks>
    private static bool WarpPointer(PixelPoint screen)
    {
        if (!_displayResolved)
        {
            _displayResolved = true;
            // DllNotFoundException when libX11 is absent, e.g. a Wayland-only container.
            try { _display = XOpenDisplay(IntPtr.Zero); }
            catch { _display = IntPtr.Zero; }
        }
        if (_display == IntPtr.Zero)
            return false;

        try
        {
            XWarpPointer(_display, IntPtr.Zero, XDefaultRootWindow(_display),
                0, 0, 0, 0, screen.X, screen.Y);
            XFlush(_display);
            return true;
        }
        catch
        {
            _display = IntPtr.Zero;
            return false;
        }
    }

    /// <summary>
    /// Reports a channel's state for <paramref name="path"/>: null when no channel exists,
    /// false when one exists, true when a keyframe sits at the current time.
    /// </summary>
    /// <remarks>
    /// A hook rather than a lookup so the control stays independent of the host. WPF reaches
    /// through <c>Application.Current.MainWindow.DataContext</c> here, and says "ugh" in a
    /// comment while doing it.
    /// </remarks>
    public static Func<string, bool?> ChannelStateProvider { get; set; }

    /// <summary>True when the live value differs from what the channel evaluates to.</summary>
    public static Func<string, double, bool> ChannelModifiedProvider { get; set; }

    /// <summary>Raised by the host when the dopesheet or the playhead changed.</summary>
    public static event Action AnimationStateChanged;

    /// <summary>Asks the host to key this parameter: (label, channel path, value).</summary>
    public static Action<string, string, double> AnimateRequested { get; set; }

    public static void NotifyAnimationStateChanged() => AnimationStateChanged?.Invoke();

    // ------------------------------------------------------------------ properties

    public static readonly StyledProperty<double> ValueProperty =
        AvaloniaProperty.Register<ValueSlider, double>(nameof(Value),
            defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);

    public double Value
    {
        get => GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    public static readonly StyledProperty<string> LabelProperty =
        AvaloniaProperty.Register<ValueSlider, string>(nameof(Label));

    public string Label
    {
        get => GetValue(LabelProperty);
        set => SetValue(LabelProperty, value);
    }

    public static readonly StyledProperty<bool> IsLabelShownProperty =
        AvaloniaProperty.Register<ValueSlider, bool>(nameof(IsLabelShown), true);

    public bool IsLabelShown
    {
        get => GetValue(IsLabelShownProperty);
        set => SetValue(IsLabelShownProperty, value);
    }

    public static readonly StyledProperty<double> DefaultValueProperty =
        AvaloniaProperty.Register<ValueSlider, double>(nameof(DefaultValue));

    public double DefaultValue
    {
        get => GetValue(DefaultValueProperty);
        set => SetValue(DefaultValueProperty, value);
    }

    public static readonly StyledProperty<double> IncrementProperty =
        AvaloniaProperty.Register<ValueSlider, double>(nameof(Increment), 0.1);

    public double Increment
    {
        get => GetValue(IncrementProperty);
        set => SetValue(IncrementProperty, value);
    }

    public static readonly StyledProperty<double?> MinValueProperty =
        AvaloniaProperty.Register<ValueSlider, double?>(nameof(MinValue));

    public double? MinValue
    {
        get => GetValue(MinValueProperty);
        set => SetValue(MinValueProperty, value);
    }

    public static readonly StyledProperty<double?> MaxValueProperty =
        AvaloniaProperty.Register<ValueSlider, double?>(nameof(MaxValue));

    public double? MaxValue
    {
        get => GetValue(MaxValueProperty);
        set => SetValue(MaxValueProperty, value);
    }

    public static readonly StyledProperty<string> AnimationPathProperty =
        AvaloniaProperty.Register<ValueSlider, string>(nameof(AnimationPath));

    public string AnimationPath
    {
        get => GetValue(AnimationPathProperty);
        set => SetValue(AnimationPathProperty, value);
    }

    public static readonly StyledProperty<string> HelpTextProperty =
        AvaloniaProperty.Register<ValueSlider, string>(nameof(HelpText));

    /// <summary>The parameter's explanatory text, shown on hover and in the context menu.</summary>
    public string HelpText
    {
        get => GetValue(HelpTextProperty);
        set => SetValue(HelpTextProperty, value);
    }

    public static readonly StyledProperty<ValueSliderSettings> SliderSettingsProperty =
        AvaloniaProperty.Register<ValueSlider, ValueSliderSettings>(nameof(SliderSettings));

    public ValueSliderSettings SliderSettings
    {
        get => GetValue(SliderSettingsProperty);
        set => SetValue(SliderSettingsProperty, value);
    }

    // --------------------------------------------------------------------- state

    private TextBlock _labelText;
    private TextBlock _valueText;
    private TextBox _valueEditor;
    private Border _valueField;
    private Grid _labelRow;
    private Button _down, _up, _diamond1, _diamond2;

    private Action _valueWillChange;
    private Action<double> _valueChanged;
    private Action _draggingStarted;
    private Action<double> _draggingCompleted;

    /// <summary>Modifiers seen on the most recent pointer press, for the step multiplier.</summary>
    private KeyModifiers _modifiers;

    private bool _pressed, _dragging, _editing;
    private Point _dragOrigin;
    private double _dragStartValue;

    /// <summary>Pointer X in screen pixels at the previous move, for delta accumulation.</summary>
    private int _lastScreenX;

    /// <summary>Value before an edit began, restored on Escape.</summary>
    private double _lastValue;

    /// <summary>Set while writing the value into the editor, so its handlers no-op.</summary>
    private bool _syncing;

    private double IncrementMultiplier => 1.0
        * (_modifiers.HasFlag(KeyModifiers.Control) ? 10 : 1)
        * (_modifiers.HasFlag(KeyModifiers.Shift) ? 0.1 : 1);

    public ValueSlider()
    {
        InitializeComponent();

        _labelRow = this.FindControl<Grid>("LabelRow");
        _labelText = this.FindControl<TextBlock>("LabelText");
        _valueText = this.FindControl<TextBlock>("ValueText");
        _valueEditor = this.FindControl<TextBox>("ValueEditor");
        _valueField = this.FindControl<Border>("ValueField");
        _down = this.FindControl<Button>("DownButton");
        _up = this.FindControl<Button>("UpButton");
        _diamond1 = this.FindControl<Button>("Diamond1");
        _diamond2 = this.FindControl<Button>("Diamond2");

        // Click carries no modifier state, so grab it from the press that precedes it.
        foreach (Button b in new[] { _down, _up })
            b.AddHandler(PointerPressedEvent, (_, e) => _modifiers = e.KeyModifiers,
                RoutingStrategies.Tunnel);

        _down.Click += (_, _) => Step(-1);
        _up.Click += (_, _) => Step(+1);

        _diamond1.Click += (_, _) => Animate();
        _diamond2.Click += (_, _) => Animate();

        _valueField.PointerPressed += OnFieldPressed;
        _valueField.PointerMoved += OnFieldMoved;
        _valueField.PointerReleased += OnFieldReleased;

        _valueEditor.KeyDown += OnEditorKeyDown;
        _valueEditor.LostFocus += (_, _) => CommitEdit();
        // valueEditor_MouseDoubleClick: reset to the default, then select the whole value so
        // it can be typed straight over.
        //
        // NOT DoubleTapped. Avalonia's Gestures raises that only when both presses hit the
        // SAME element — which here they never can, because the first click lands on the value
        // field and that field swaps itself for this editor, so the second lands on a
        // different control. The handler simply never ran: double-click neither reset the
        // value nor corrected the selection. The editor's own press does carry
        // ClickCount == 2, so that is what to hang it on.
        //
        // Tunnel, so this runs before TextBox's class handler; the select-all is then posted
        // to land after that handler has done its own word selection, which otherwise leaves a
        // single run highlighted ("55" of "14.55").
        _valueEditor.AddHandler(PointerPressedEvent, (_, e) =>
        {
            if (e.ClickCount < 2)
                return;
            ResetValue();
            StartEditing();
            Dispatcher.UIThread.Post(() =>
            {
                if (_editing)
                    _valueEditor.SelectAll();
            }, DispatcherPriority.Input);
        }, RoutingStrategies.Tunnel);

        ContextMenu = BuildContextMenu();

        AnimationStateChanged += UpdateAnimatedSymbol;
        DetachedFromVisualTree += (_, _) => AnimationStateChanged -= UpdateAnimatedSymbol;

        RefreshText();
        UpdateAnimatedSymbol();
        UpdateLayoutMode();
    }

    private void InitializeComponent() => AvaloniaXamlLoader.Load(this);

    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
    {
        base.OnPropertyChanged(change);

        if (change.Property == SliderSettingsProperty)
            ApplySettings(change.GetNewValue<ValueSliderSettings>());
        else if (change.Property == ValueProperty)
        {
            RefreshText();
            UpdateAnimatedSymbol();
        }
        else if (change.Property == IncrementProperty)
            RefreshText();
        else if (change.Property == LabelProperty)
            _labelText.Text = Label;
        else if (change.Property == IsLabelShownProperty)
            UpdateLayoutMode();
        else if (change.Property == AnimationPathProperty)
            UpdateAnimatedSymbol();
        else if (change.Property == HelpTextProperty)
            ToolTip.SetTip(this, HelpText);
    }

    /// <summary>
    /// Copies the supplied settings onto the control. Only non-null members are applied, so
    /// "unspecified" leaves whatever the call site set directly — the same contract the WPF
    /// version implements via <c>BindingOperations.IsDataBound</c>.
    /// </summary>
    private void ApplySettings(ValueSliderSettings s)
    {
        if (s is null)
            return;

        if (s.Label is not null)
            Label = s.Label;
        if (s.IsLabelShown is bool shown)
            IsLabelShown = shown;
        if (s.DefaultValue is double d)
            DefaultValue = d;
        if (s.MinValue is double min)
            MinValue = min;
        if (s.MaxValue is double max)
            MaxValue = max;
        if (s.Increment is double inc)
            Increment = inc;
        if (s.AnimationPath is not null)
            AnimationPath = s.AnimationPath;
        if (s.ToolTip is not null)
            HelpText = s.ToolTip;

        _valueWillChange = s.ValueWillChange ?? _valueWillChange;
        _valueChanged = s.ValueChanged ?? _valueChanged;
        _draggingStarted = s.ValueDraggingStarted ?? _draggingStarted;
        _draggingCompleted = s.ValueDraggingCompleted ?? _draggingCompleted;

        RefreshText();
        UpdateAnimatedSymbol();
        UpdateLayoutMode();
    }

    private void UpdateLayoutMode()
    {
        if (_labelRow is null)
            return;
        _labelRow.IsVisible = IsLabelShown;
        // The diamond lives in the label row when there is one, inline otherwise.
        _diamond1.IsVisible = IsLabelShown;
        _diamond2.IsVisible = !IsLabelShown;
    }

    // ---------------------------------------------------------------- formatting

    /// <summary>Integers show no decimals; everything else shows four, as on Windows.</summary>
    private string Format(double v) =>
        v.ToString(Increment % 1 == 0 ? "N0" : "N4", CultureInfo.CurrentCulture);

    private void RefreshText()
    {
        if (_valueText is not null)
            _valueText.Text = Format(Value);
    }

    /// <summary>
    /// Paints the keyframe diamond. Three states, matching the original and the wiki:
    /// <c>◇</c> the parameter is animatable but has no channel; <c>◆</c> a channel exists;
    /// <c>◈</c> a keyframe sits at the current time. Grey when there is no channel, orange
    /// when the live value has drifted from the channel's evaluated value, white otherwise.
    /// </summary>
    private void UpdateAnimatedSymbol()
    {
        if (_diamond1 is null)
            return;

        string symbol = " ";
        IBrush colour = Brushes.Gray;

        if (!string.IsNullOrEmpty(AnimationPath))
        {
            bool? state = ChannelStateProvider?.Invoke(AnimationPath);
            symbol = state switch
            {
                null => "◇",
                false => "◆",
                true => "◈",
            };

            if (state is not null)
            {
                bool modified = ChannelModifiedProvider?.Invoke(AnimationPath, Value) ?? false;
                colour = modified ? Brushes.Orange : Brushes.White;
            }
        }

        foreach (Button b in new[] { _diamond1, _diamond2 })
        {
            // A TextBlock, NOT a bare string. Assigning a string makes ContentPresenter wrap it
            // in an AccessText, which sets ClipToBounds and so clips the glyph to its measured
            // ADVANCE width. ◇ ◆ ◈ are not in Inter, so they resolve through font fallback, and
            // the fallback face inks them wider than the advance it reports — which sliced the
            // right vertex off every keyframe diamond with a clean vertical cut. Measured:
            // button 26x27 with correct 8px padding, AccessText 10x11 and clipping. A TextBlock
            // hosts the same glyph without the clip.
            if (b.Content is TextBlock existing)
            {
                existing.Text = symbol;
                existing.Foreground = colour;
            }
            else
            {
                b.Content = new TextBlock
                {
                    Text = symbol,
                    Foreground = colour,
                    ClipToBounds = false,
                    HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
                    VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
                };
            }
            b.Foreground = colour;
            b.IsEnabled = !string.IsNullOrEmpty(AnimationPath);
        }
    }

    // -------------------------------------------------------------------- editing

    private double Constrain(double v)
    {
        if (MinValue is double min)
            v = Math.Max(min, v);
        if (MaxValue is double max)
            v = Math.Min(max, v);
        return v;
    }

    private void Step(int direction)
    {
        _valueWillChange?.Invoke();
        Value = Constrain(Value + direction * Increment * IncrementMultiplier);
        _valueChanged?.Invoke(Value);
    }

    private void ResetValue()
    {
        _valueWillChange?.Invoke();
        // As in Apophysis: back to the default, or to zero if already at the default.
        Value = Value == DefaultValue ? 0.0 : DefaultValue;
        _valueChanged?.Invoke(Value);
    }

    private void Animate()
    {
        if (string.IsNullOrEmpty(AnimationPath))
            return;
        AnimateRequested?.Invoke(Label, AnimationPath, Value);
        UpdateAnimatedSymbol();
    }

    private void StartEditing()
    {
        _lastValue = Value;
        _editing = true;
        _syncing = true;
        try
        {
            _valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture);
        }
        finally { _syncing = false; }

        _valueEditor.IsVisible = true;
        _valueField.IsVisible = false;
        _valueEditor.Focus();
        _valueEditor.SelectAll();
    }

    private void CommitEdit()
    {
        if (!_editing)
            return;
        _editing = false;
        _valueEditor.IsVisible = false;
        _valueField.IsVisible = true;

        if (_syncing)
            return;
        if (double.TryParse(_valueEditor.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out double parsed)
            || double.TryParse(_valueEditor.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed))
        {
            _valueWillChange?.Invoke();
            Value = Constrain(parsed);
            _valueChanged?.Invoke(Value);
        }
    }

    private void OnEditorKeyDown(object sender, KeyEventArgs e)
    {
        _modifiers = e.KeyModifiers;
        switch (e.Key)
        {
            case Key.Enter:
                e.Handled = true;
                CommitEdit();
                break;
            case Key.Up:
                e.Handled = true;
                Step(+1);
                _syncing = true;
                try { _valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture); }
                finally { _syncing = false; }
                break;
            case Key.Down:
                e.Handled = true;
                Step(-1);
                _syncing = true;
                try { _valueEditor.Text = Value.ToString("0.####", CultureInfo.CurrentCulture); }
                finally { _syncing = false; }
                break;
            case Key.Escape:
                e.Handled = true;
                _editing = false;
                _valueEditor.IsVisible = false;
                _valueField.IsVisible = true;
                Value = _lastValue;
                _valueChanged?.Invoke(Value);
                break;
        }
    }

    // ------------------------------------------------------------------ drag-scrub

    private void OnFieldPressed(object sender, PointerPressedEventArgs e)
    {
        if (!e.GetCurrentPoint(_valueField).Properties.IsLeftButtonPressed)
            return;

        _modifiers = e.KeyModifiers;
        _pressed = true;
        _dragging = false;
        _dragOrigin = e.GetPosition(this);
        _dragStartValue = Value;
        _lastScreenX = ScreenX(e);
        e.Pointer.Capture(_valueField);
        e.Handled = true;
    }

    private void OnFieldMoved(object sender, PointerEventArgs e)
    {
        if (!_pressed)
            return;

        _modifiers = e.KeyModifiers;

        if (!_dragging)
        {
            // A press that never moves is a click, which opens the editor instead.
            if (Math.Abs(e.GetPosition(this).X - _dragOrigin.X) < 2)
                return;
            _dragging = true;
            _valueWillChange?.Invoke();
            _draggingStarted?.Invoke();
            Cursor = new Cursor(StandardCursorType.None);
            _lastScreenX = ScreenX(e);
            return;
        }

        int screenX = ScreenX(e);
        int delta = screenX - _lastScreenX;
        _lastScreenX = screenX;

        // A jump of more than half the monitor is the wrap below, not the user's hand. Ignoring
        // it by MAGNITUDE is what makes this robust: it does not matter how late the warp's own
        // move event arrives, or how many queued events precede it.
        PixelRect screen = MonitorBounds();
        if (screen.Width > 0 && Math.Abs(delta) > screen.Width / 2)
            return;

        double target = Constrain(Value + delta * Increment * IncrementMultiplier);
        if (target != Value)
        {
            Value = target;
            _valueChanged?.Invoke(Value);
        }

        WrapPointerAtScreenEdge(e, screen);
    }

    /// <summary>
    /// Teleports the pointer to the opposite edge when it reaches one, so a scrub never runs out
    /// of desk — the behaviour Blender calls continuous grab.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This replaces an earlier attempt that warped the pointer back to the drag origin on EVERY
    /// move. That fought pointer-event latency: each queued move re-applied a stale offset which
    /// the rebase then banked, so values crept in one direction on their own and bounded ones
    /// pinned themselves to a limit. Wrapping only AT THE EDGE makes warps rare, and the
    /// magnitude guard above makes the induced event harmless whenever it lands.
    /// </para>
    /// <para>
    /// Wrapping is per-MONITOR, not across the whole virtual desktop: on a multi-head setup the
    /// pointer should stay on the screen holding the window rather than traversing all of them
    /// before coming back.
    /// </para>
    /// </remarks>
    private void WrapPointerAtScreenEdge(PointerEventArgs e, PixelRect screen)
    {
        if (screen.Width <= 0)
            return;

        const int margin = 2;
        int x = _lastScreenX;
        int? destination = null;

        if (x <= screen.X + margin)
            destination = screen.X + screen.Width - margin - 1;
        else if (x >= screen.X + screen.Width - margin - 1)
            destination = screen.X + margin;

        if (destination is not int target)
            return;

        int y = ScreenY(e);
        if (!PointerWarp(new PixelPoint(target, y)))
            return;

        // Adopt the warped position immediately, so the move event the warp raises measures a
        // delta of roughly zero rather than the width of the screen.
        _lastScreenX = target;
    }

    /// <summary>Bounds of the monitor showing this control, or empty if unknown.</summary>
    private PixelRect MonitorBounds()
    {
        try
        {
            if (TopLevel.GetTopLevel(this) is not Window window)
                return default;
            Avalonia.Platform.Screen screen = window.Screens?.ScreenFromVisual(this);
            return screen?.Bounds ?? default;
        }
        catch
        {
            return default;
        }
    }

    private int ScreenX(PointerEventArgs e) => ScreenPoint(e).X;
    private int ScreenY(PointerEventArgs e) => ScreenPoint(e).Y;

    private PixelPoint ScreenPoint(PointerEventArgs e)
    {
        try { return this.PointToScreen(e.GetPosition(this)); }
        catch { return default; }
    }

    /// <summary>Puts the pointer back at the drag origin. False if the platform cannot warp.</summary>
    private bool PinPointerToDragOrigin()
    {
        try
        {
            return PointerWarp(this.PointToScreen(_dragOrigin));
        }
        catch
        {
            // PointToScreen throws if the control left the visual tree mid-drag.
            return false;
        }
    }

    private void OnFieldReleased(object sender, PointerReleasedEventArgs e)
    {
        if (!_pressed)
            return;
        _pressed = false;
        e.Pointer.Capture(null);
        Cursor = Cursor.Default;

        if (_dragging)
        {
            _dragging = false;
            // WPF also warps home on mouse-up; the pointer is normally already there.
            PinPointerToDragOrigin();
            _draggingCompleted?.Invoke(Value);
        }
        else
        {
            StartEditing();
        }
    }

    // ---------------------------------------------------------------- context menu

    private ContextMenu BuildContextMenu()
    {
        var reset = new MenuItem { Header = "Reset" };
        reset.Click += (_, _) => ResetValue();

        var animate = new MenuItem { Header = "Animate" };
        animate.Click += (_, _) => Animate();

        var menu = new ContextMenu();
        var items = new Avalonia.Controls.Controls { reset, animate };
        menu.ItemsSource = items;

        // The help text and the channel path are shown on open, so they track the
        // parameter the slider is currently bound to.
        menu.Opening += (_, _) =>
        {
            animate.IsEnabled = !string.IsNullOrEmpty(AnimationPath);

            while (items.Count > 2)
                items.RemoveAt(items.Count - 1);

            if (!string.IsNullOrWhiteSpace(HelpText))
            {
                items.Add(new Separator());
                items.Add(new MenuItem
                {
                    Header = new TextBlock
                    {
                        Text = HelpText,
                        MaxWidth = 300,
                        TextWrapping = TextWrapping.Wrap,
                    },
                    IsHitTestVisible = false,
                });
            }

            if (!string.IsNullOrEmpty(AnimationPath))
            {
                items.Add(new Separator());
                items.Add(new MenuItem
                {
                    Header = new TextBlock
                    {
                        Text = AnimationPath,
                        FontFamily = new FontFamily("monospace"),
                        FontSize = 11,
                        Foreground = Brushes.Gray,
                    },
                    IsHitTestVisible = false,
                });
            }
        };

        return menu;
    }
}