OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / Controls / PaletteSlider.cs

176 lines · csharp ref:
using System;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;

using IFSEngine.Model;

namespace IFSRenderer.AvaloniaShell.Controls;

/// <summary>
/// The palette as a gradient bar with a draggable swatch that shows the colour it is over.
/// </summary>
/// <remarks>
/// <para>
/// Port of <c>WpfDisplay/Views/PaletteSlider.xaml</c>, which is a <c>Slider</c> retemplated so
/// its track IS the palette gradient and its thumb is a 30x30 rectangle, stroked black at 2px
/// and 3px on hover, filled with the sampled colour. It is two-way bound to an iterator's
/// <c>ColorIndex</c>.
/// </para>
/// <para>
/// Without it an iterator's colour can only be chosen as an abstract 0-1 number with no
/// indication of which colour that lands on — the port had the palette as a read-only strip and
/// the index as a bare ValueSlider, so picking a hue was trial and error.
/// </para>
/// </remarks>
public sealed class PaletteSlider : Control
{
    /// <summary>Thumb geometry, from PaletteSlider.xaml:40-45.</summary>
    private const double ThumbSize = 30, StrokeNormal = 2, StrokeHover = 3;

    /// <summary>Gradient stops sampled across the palette; enough to read as continuous.</summary>
    private const int Stops = 96;

    public static readonly StyledProperty<double> ColorIndexProperty =
        AvaloniaProperty.Register<PaletteSlider, double>(nameof(ColorIndex),
            defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);

    /// <summary>Position along the palette, 0 to 1.</summary>
    public double ColorIndex
    {
        get => GetValue(ColorIndexProperty);
        set => SetValue(ColorIndexProperty, value);
    }

    public static readonly StyledProperty<ColorPalette> PaletteProperty =
        AvaloniaProperty.Register<PaletteSlider, ColorPalette>(nameof(Palette));

    public ColorPalette Palette
    {
        get => GetValue(PaletteProperty);
        set => SetValue(PaletteProperty, value);
    }

    /// <summary>Raised while the value is being dragged, so the host can apply it.</summary>
    public event Action<double> ColorIndexChanged;

    /// <summary>Raised once when a drag begins, for the undo snapshot.</summary>
    public event Action DragStarted;

    private bool _dragging;
    private bool _hover;

    static PaletteSlider()
    {
        AffectsRender<PaletteSlider>(ColorIndexProperty, PaletteProperty);
    }

    public PaletteSlider()
    {
        Height = ThumbSize + 4;
        Cursor = new Cursor(StandardCursorType.SizeWestEast);
    }

    protected override void OnPointerPressed(PointerPressedEventArgs e)
    {
        base.OnPointerPressed(e);
        if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
            return;
        _dragging = true;
        DragStarted?.Invoke();
        e.Pointer.Capture(this);
        Apply(e.GetPosition(this).X);
        e.Handled = true;
    }

    protected override void OnPointerMoved(PointerEventArgs e)
    {
        base.OnPointerMoved(e);
        if (_dragging)
            Apply(e.GetPosition(this).X);
    }

    protected override void OnPointerReleased(PointerReleasedEventArgs e)
    {
        base.OnPointerReleased(e);
        if (!_dragging)
            return;
        _dragging = false;
        e.Pointer.Capture(null);
    }

    protected override void OnPointerEntered(PointerEventArgs e)
    {
        base.OnPointerEntered(e);
        _hover = true;
        InvalidateVisual();
    }

    protected override void OnPointerExited(PointerEventArgs e)
    {
        base.OnPointerExited(e);
        _hover = false;
        InvalidateVisual();
    }

    /// <summary>Maps an x position to 0..1, keeping the thumb fully on the bar.</summary>
    private void Apply(double x)
    {
        double usable = Math.Max(Bounds.Width - ThumbSize, 1);
        double t = Math.Clamp((x - ThumbSize / 2) / usable, 0.0, 1.0);
        ColorIndex = t;
        ColorIndexChanged?.Invoke(t);
        InvalidateVisual();
    }

    /// <summary>The palette colour at <paramref name="t"/>, or grey with no palette.</summary>
    private Color Sample(double t)
    {
        ColorPalette palette = Palette;
        if (palette is null || palette.KeyColors.Count == 0)
            return Colors.Gray;
        // ColorPalette samples through its own InterpolationMode rather than assuming linear RGB.
        System.Numerics.Vector4 c = palette.SampleGradient(Math.Clamp(t, 0, 1));
        return Color.FromRgb(Channel(c.X), Channel(c.Y), Channel(c.Z));

        static byte Channel(float v) => (byte)Math.Clamp(v * 255f, 0f, 255f);
    }

    public override void Render(DrawingContext context)
    {
        base.Render(context);
        if (Bounds.Width <= 0 || Bounds.Height <= 0)
            return;

        // The gradient runs the full width; the thumb travels inside it, so both use the same
        // 0..1 domain and the swatch always shows the colour directly beneath it.
        var brush = new LinearGradientBrush
        {
            StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
            EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative),
        };
        for (int i = 0; i < Stops; i++)
        {
            double t = i / (double)(Stops - 1);
            brush.GradientStops.Add(new GradientStop(Sample(t), t));
        }

        double barHeight = Math.Max(Bounds.Height - 8, 6);
        var bar = new Rect(0, (Bounds.Height - barHeight) / 2, Bounds.Width, barHeight);
        context.DrawRectangle(brush, null, bar, 3, 3);

        double usable = Math.Max(Bounds.Width - ThumbSize, 1);
        double centre = ThumbSize / 2 + Math.Clamp(ColorIndex, 0, 1) * usable;
        var thumb = new Rect(centre - ThumbSize / 2, (Bounds.Height - ThumbSize) / 2,
            ThumbSize, ThumbSize);

        double stroke = _hover || _dragging ? StrokeHover : StrokeNormal;
        context.DrawRectangle(
            new SolidColorBrush(Sample(ColorIndex)),
            new Pen(Brushes.Black, stroke),
            thumb, 2, 2);
    }
}