Skip to content

Instantly share code, notes, and snippets.

@andrew-raphael-lukasik
Last active November 27, 2025 10:00
Show Gist options
  • Select an option

  • Save andrew-raphael-lukasik/e7476208779f70e66fbedf5eb10aa2f8 to your computer and use it in GitHub Desktop.

Select an option

Save andrew-raphael-lukasik/e7476208779f70e66fbedf5eb10aa2f8 to your computer and use it in GitHub Desktop.
basic color picker for UI Toolkit

Color picker / UI Toolkit

GIF 08 11 2023 01-37-22

// src*: https://gist.github.com/andrew-raphael-lukasik/e7476208779f70e66fbedf5eb10aa2f8
using UnityEngine;
using UnityEngine.UIElements;
[UnityEngine.Scripting.Preserve]
public class ColorPickerElement : VisualElement
{
public new class UxmlFactory : UxmlFactory<ColorPickerElement,UxmlTraits> {}
public new class UxmlTraits : VisualElement.UxmlTraits
{
UxmlFloatAttributeDescription hueAttr = new UxmlFloatAttributeDescription{ name="hue" , defaultValue=0.5f };
UxmlFloatAttributeDescription brightnessAttr = new UxmlFloatAttributeDescription{ name="brightness" , defaultValue=1f };
public override void Init ( VisualElement ve , IUxmlAttributes attributes , CreationContext context )
{
base.Init( ve , attributes , context );
var instance = (ColorPickerElement) ve;
instance.hue = hueAttr.GetValueFromBag(attributes,context);
instance.brightness = brightnessAttr.GetValueFromBag(attributes,context);
}
}
public float hue { get; set; }
public float brightness { get; set; }
public Color color => Color.HSVToRGB(1-hue%1,1,brightness);
public Gradient circleGradient { get; set; } = new Gradient{
mode = GradientMode.Blend ,
colorKeys = new GradientColorKey[]{
new GradientColorKey( Color.HSVToRGB(0,1,1) , 0 ) ,
new GradientColorKey( Color.HSVToRGB(1*1f/6f,1,1) , 1*1f/6f ) ,
new GradientColorKey( Color.HSVToRGB(2*1f/6f,1,1) , 2*1f/6f ) ,
new GradientColorKey( Color.HSVToRGB(3*1f/6f,1,1) , 3*1f/6f ) ,
new GradientColorKey( Color.HSVToRGB(4*1f/6f,1,1) , 4*1f/6f ) ,
new GradientColorKey( Color.HSVToRGB(5*1f/6f,1,1) , 5*1f/6f ) ,
new GradientColorKey( Color.HSVToRGB(1,1,1) , 1 ) ,
}
};
public ColorPickerElement ()
{
generateVisualContent += OnGenerateVisualContent;
this.RegisterCallback<ClickEvent>( OnMouseClicked );
}
void OnMouseClicked ( ClickEvent evt )
{
Vector2 dir = (Vector2)evt.localPosition - contentRect.center;
hue = 0.25f + ( Mathf.Atan2(-dir.y,dir.x) / Mathf.PI ) * -0.5f;
Rect rect = contentRect;
float swh = Mathf.Min( rect.width , rect.height );// smaller dimension
brightness = dir.magnitude / (swh*0.4f);
this.MarkDirtyRepaint();
}
void OnGenerateVisualContent ( MeshGenerationContext mgc )
{
Rect rect = contentRect;
float swh = Mathf.Min( rect.width , rect.height );// smaller dimension
if( swh<0.01f ) return;// skip rendering when collapsed
var paint = mgc.painter2D;
float circleRadius = swh*0.4f;
float gradientWidth = swh*0.05f;
// selected color circle
paint.BeginPath();
{
paint.Arc( rect.center , circleRadius-gradientWidth/2 , 0 , 360 );
paint.fillColor = color;
paint.Fill();
}
paint.ClosePath();
// color ring
paint.BeginPath();
{
paint.Arc( rect.center , circleRadius , 270-0.001f , -90 , ArcDirection.CounterClockwise );
paint.lineWidth = gradientWidth + 0.2f;
paint.strokeColor = Color.black;
paint.Stroke();// border
paint.lineWidth = gradientWidth;
paint.strokeGradient = circleGradient;
paint.Stroke();// hues
}
paint.ClosePath();
// hue position marker
paint.BeginPath();
{
float hueAngle = -Mathf.PI/2 + hue * Mathf.PI*2;
paint.Arc( rect.center + Vector2.Scale(new Vector2(circleRadius,circleRadius),new Vector2(Mathf.Cos(hueAngle),Mathf.Sin(hueAngle))) , swh*0.03f , 0 , 360 );
paint.lineWidth = 0.4f;
paint.strokeColor = Color.white;
paint.Stroke();
}
paint.ClosePath();
}
}
@Agoxandr
Copy link

Agoxandr commented Jun 2, 2025

I made some improvements. Thanks a lot for this. There is still some stuff to improve, but it looks very similar to the built in picker.

using UnityEngine;
using UnityEngine.UIElements;

namespace Runtime.UI
{
    [UxmlElement]
    public partial class ColorPickerElement : VisualElement
    {
        [UxmlAttribute] public float Hue { get; set; }
        [UxmlAttribute] public float Saturation { get; set; }
        [UxmlAttribute] public float Brightness { get; set; }

        public Color Color => Color.HSVToRGB(Hue, Saturation, Brightness);

        public Gradient CircleGradient { get; set; } = new()
        {
            mode = GradientMode.Blend,
            colorKeys = new GradientColorKey[]
            {
                new(Color.HSVToRGB(0, 1, 1), 0),
                new(Color.HSVToRGB(1 * 1f / 6f, 1, 1), 1 * 1f / 6f),
                new(Color.HSVToRGB(2 * 1f / 6f, 1, 1), 2 * 1f / 6f),
                new(Color.HSVToRGB(3 * 1f / 6f, 1, 1), 3 * 1f / 6f),
                new(Color.HSVToRGB(4 * 1f / 6f, 1, 1), 4 * 1f / 6f),
                new(Color.HSVToRGB(5 * 1f / 6f, 1, 1), 5 * 1f / 6f),
                new(Color.HSVToRGB(1, 1, 1), 1)
            }
        };

        private readonly Texture2D _gradientTexture;
        private Vector2 _direction = Vector2.right;
        private Vector2 _position;

        public ColorPickerElement()
        {
            generateVisualContent += OnGenerateVisualContent;
            RegisterCallback<ClickEvent>(OnMouseClicked);

            _gradientTexture = new Texture2D(16, 16, TextureFormat.RGB24, false)
            {
                filterMode = FilterMode.Bilinear,
                hideFlags = HideFlags.HideAndDontSave,
                wrapMode = TextureWrapMode.Clamp
            };
            UpdateGradientTexture();
            var image = new Image
            {
                image = _gradientTexture,
                style =
                {
                    width = Length.Percent(50f),
                    height = Length.Percent(50f),
                    marginTop = Length.Auto(),
                    marginRight = Length.Auto(),
                    marginBottom = Length.Auto(),
                    marginLeft = Length.Auto()
                }
            };
            image.generateVisualContent += context =>
            {
                var rect = contentRect;
                var swh = Mathf.Min(rect.width, rect.height);
                var paint = context.painter2D;

                // Image position marker
                paint.BeginPath();
                {
                    paint.Arc(_position, swh * 0.025f - 2f, 0, 360);

                    paint.lineWidth = 1f + 2f;
                    paint.strokeColor = Color.black;
                    paint.Stroke();

                    paint.lineWidth = 1f;
                    paint.strokeColor = Color.white;
                    paint.Stroke();
                }
                paint.ClosePath();
            };
            image.RegisterCallback<ClickEvent>(evt =>
            {
                var rect = contentRect;
                var width = rect.width * .5f;
                var height = rect.height * .5f;
                var swh = Mathf.Min(width, height);
                var widthReduction = (width - swh) * .5f;
                var heightReduction = (height - swh) * .5f;
                var position = (Vector2)evt.localPosition;
                var realWidth = width - widthReduction;
                var realHeight = height - heightReduction;
                if (position.x >= widthReduction && position.x <= realWidth &&
                    position.y >= heightReduction && position.y <= realHeight)
                {
                    _position = position;
                    Saturation = (position.x - widthReduction) / (realWidth - widthReduction);
                    Brightness = (position.y - heightReduction) / (realHeight - heightReduction);
                    image.MarkDirtyRepaint();
                }
            });
            Add(image);
        }

        ~ColorPickerElement()
        {
            Object.Destroy(_gradientTexture);
        }

        private float GetAngleFromVector(float x, float y)
        {
            var radians = Mathf.Atan2(y, x);
            var degrees = radians * (180f / Mathf.PI);
            return ((degrees + 360) % 360 + 360) % 360 / 360f;
        }

        private void OnMouseClicked(ClickEvent evt)
        {
            var direction = (Vector2)evt.localPosition - contentRect.center;

            var rect = contentRect;
            var swh = Mathf.Min(rect.width, rect.height);
            var circleRadius = swh * 0.425f;
            var gradientOffset = swh * 0.1f * .5f;
            var length = direction.magnitude;
            if (length < circleRadius + gradientOffset && length > circleRadius - gradientOffset)
            {
                _direction = direction;
                var angle = GetAngleFromVector(direction.x, -direction.y);
                Hue = angle;
                UpdateGradientTexture();
                MarkDirtyRepaint();
            }
        }

        private void OnGenerateVisualContent(MeshGenerationContext context)
        {
            var rect = contentRect;
            var swh = Mathf.Min(rect.width, rect.height);
            var paint = context.painter2D;
            var circleRadius = swh * 0.425f;
            var gradientWidth = swh * 0.1f;

            // Color ring
            paint.BeginPath();
            {
                paint.Arc(rect.center, circleRadius, 270f, -90f, ArcDirection.CounterClockwise);

                paint.lineWidth = gradientWidth;
                paint.strokeGradient = CircleGradient;
                paint.Stroke();
            }
            paint.ClosePath();

            // Hue position marker
            paint.BeginPath();
            {
                paint.Arc(
                    rect.center + _direction.normalized * circleRadius,
                    swh * 0.05f - 2f, 0, 360
                );

                paint.lineWidth = 1f + 2f;
                paint.strokeColor = Color.black;
                paint.Stroke();

                paint.lineWidth = 1f;
                paint.strokeColor = Color.white;
                paint.Stroke();
            }
            paint.ClosePath();
        }

        private void UpdateGradientTexture()
        {
            if (!_gradientTexture) return;
            var pixels = new Color[_gradientTexture.width * _gradientTexture.height];

            for (var x = 0; x < _gradientTexture.width; x++)
            {
                for (var y = 0; y < _gradientTexture.height; y++)
                {
                    pixels[x * _gradientTexture.width + y] = Color.HSVToRGB(
                        Hue,
                        (float)y / _gradientTexture.height,
                        (float)x / _gradientTexture.width
                    );
                }
            }

            _gradientTexture.SetPixels(pixels);
            _gradientTexture.Apply();
        }
    }
}

@LeoWillmann
Copy link

LeoWillmann commented Nov 4, 2025

Thank you andrew-raphael-lukasik and Agoxandr for your work and revisions on the color picker. I've made some adjustments to suit my own needs, but I’d still love to share them for anyone out there that needs a color picker.

ColorPickerElement.mp4

Major changes to the color picker:

  • Has on color change events, write picker.OnColorChange += <your method> to subscribe to color change events.
  • UI updates when changing the HSB values (in code or in the UI Builder)
  • Has a top bar to display the old color and the currently selected color.
  • Upgraded pointer interaction to click and hold to change values.

UxmlColorPicker.cs

using System;
using UnityEngine;
using UnityEngine.UIElements;
using Object = UnityEngine.Object;

[UxmlElement]
    public partial class UxmlColorPicker : VisualElement
    {
        // style sheet specific:
        private const string ColorDisplay = "colorDisplay";
        private const string ColorDisplayActive = "active";
        private const string ColorDisplayPast = "past";
        
        // runtime render constants:
        private const float PositionMarkerOutlineThickness = 2f;
        private const float ImageMarkerRadiusFactor = 0.05f;
        private const float MarkerLineWidth = 3f;
        private const float ColorWheelThickness = 0.1f;
        private const float ColorWheelCircleRadiusFactor = 0.425f;
        private const float ImageRectSizePercent = 50f;

        #region Properties

        private float _hue;

        [UxmlAttribute, Range(0f, 0.999f)]
        public float Hue
        {
            get => _hue;
            set
            {
                _hue = Math.Clamp(value, 0f, 1f);
                UpdateGradientTexture();
                OnColorUpdate();
                _colorWheel.MarkDirtyRepaint();
            }
        }

        private float _saturation = 1;

        [UxmlAttribute, Range(0f, 0.999f)]
        public float Saturation
        {
            get => _saturation;
            set
            {
                _saturation = Math.Clamp(value, 0f, 1f);
                _innerImage.MarkDirtyRepaint();
                OnColorUpdate();
            }
        }

        private float _brightness = 1;

        [UxmlAttribute, Range(0f, 0.999f)]
        public float Brightness
        {
            get => _brightness;
            set
            {
                _brightness = Math.Clamp(value, 0f, 1f);
                _innerImage.MarkDirtyRepaint();
                OnColorUpdate();
            }
        }

        public Color Color => Color.HSVToRGB(Hue, Saturation, Brightness);

        private Gradient CircleGradient { get; } = new()
        {
            mode = GradientMode.Blend,
            colorKeys = new GradientColorKey[]
            {
                new(Color.HSVToRGB(0, 1, 1), 0),
                new(Color.HSVToRGB(1 * 1f / 6f, 1, 1), 1 * 1f / 6f),
                new(Color.HSVToRGB(2 * 1f / 6f, 1, 1), 2 * 1f / 6f),
                new(Color.HSVToRGB(3 * 1f / 6f, 1, 1), 3 * 1f / 6f),
                new(Color.HSVToRGB(4 * 1f / 6f, 1, 1), 4 * 1f / 6f),
                new(Color.HSVToRGB(5 * 1f / 6f, 1, 1), 5 * 1f / 6f),
                new(Color.HSVToRGB(1, 1, 1), 1)
            }
        };

        /// <summary>
        /// Direction as a computed property. It converts the Hue value into a direction vector for the wheel marker
        /// and the other way around.
        /// </summary>
        private Vector2 Direction
        {
            get
            {
                var radians = (float)(Hue * 2 * Math.PI);
                return new Vector2(Mathf.Cos(radians), -Mathf.Sin(radians));
            }
            set
            {
                var degrees = Vector2.SignedAngle(Vector2.right, new Vector2(value.x, -value.y));
                Hue = (degrees + 360) % 360 / 360f;
            }
        }

        private Vector2 Position
        {
            get
            {
                var width = _innerImage.resolvedStyle.width;
                var height = _innerImage.resolvedStyle.height;
                var minSide = Mathf.Min(width, height);
                var widthReduction = (width - minSide) / 2;
                var heightReduction = (height - minSide) / 2;

                var posX = Saturation * minSide + widthReduction;
                var posY = (1 - Brightness) * minSide + heightReduction;

                return new Vector2(posX, posY);
            }
            set
            {
                var width = _innerImage.resolvedStyle.width;
                var height = _innerImage.resolvedStyle.height;
                var minSide = Mathf.Min(width, height);
                var widthReduction = (width - minSide) / 2;
                var heightReduction = (height - minSide) / 2;

                Saturation = (value.x - widthReduction) / (width - 2 * widthReduction);
                Brightness = 1 - (value.y - heightReduction) / (height - 2 * heightReduction);
            }
        }

        #endregion

        private readonly Texture2D _gradientTexture;
        private readonly VisualElement _colorDisplayActive = new() { name = ColorDisplayActive };
        private readonly VisualElement _colorDisplayPast = new() { name = ColorDisplayPast };
        private readonly VisualElement _colorWheel;

        private readonly Image _innerImage = new Image
        {
            style =
            {
                width = Length.Percent(ImageRectSizePercent),
                height = Length.Percent(ImageRectSizePercent),
                marginTop = Length.Auto(),
                marginRight = Length.Auto(),
                marginBottom = Length.Auto(),
                marginLeft = Length.Auto(),
            }
        };

        public event Action<Color> OnColorChange;

        public UxmlColorPicker()
        {
            style.flexGrow = 1;

            var colorDisplay = new VisualElement()
            {
                name = ColorDisplay
            };
            colorDisplay.Add(_colorDisplayPast);
            colorDisplay.Add(_colorDisplayActive);
            this.Add(colorDisplay);

            // Adding color wheel
            _colorWheel = new VisualElement
            {
                style =
                {
                    flexGrow = 1
                }
            };
            this.Add(_colorWheel);

            _colorWheel.generateVisualContent += OnGenerateVisualContent;
            SetWheelMouseCallbackEvents();

            // Creating gradient texture amd adding inner image
            _gradientTexture = new Texture2D(16, 16, TextureFormat.RGB24, false)
            {
                filterMode = FilterMode.Bilinear,
                hideFlags = HideFlags.HideAndDontSave,
                wrapMode = TextureWrapMode.Clamp
            };
            _innerImage.image = _gradientTexture;
            UpdateGradientTexture();
            _colorWheel.Add(_innerImage);

            _innerImage.generateVisualContent += ImageGenerateVisualContent;
            SetImageMouseCallbackEvents();

            OnColorUpdate();
        }

        ~UxmlColorPicker()
        {
            Object.Destroy(_gradientTexture);
        }

        private void OnColorUpdate()
        {
            _colorDisplayActive.style.backgroundColor = Color;
            OnColorChange?.Invoke(Color);
        }

        public void SetCurrentColor(Color color)
        {
            _colorDisplayPast.style.backgroundColor = color;
            Color.RGBToHSV(color, out var hue, out var saturation, out var brightness);
            Hue = hue;
            Saturation = saturation;
            Brightness = brightness;
        }

        #region Image

        private void SetImageMouseCallbackEvents()
        {
            _innerImage.RegisterCallback<PointerDownEvent>(evt =>
            {
                if (!IsPointerOnImage(_innerImage.WorldToLocal(evt.position)))
                    return;

                _innerImage.CaptureMouse();
                SetImagePosition(_innerImage.WorldToLocal(evt.position));
            });

            _innerImage.RegisterCallback<PointerMoveEvent>(evt =>
            {
                if (_innerImage.HasMouseCapture())
                {
                    SetImagePosition(_innerImage.WorldToLocal(evt.position));
                }
            });

            _innerImage.RegisterCallback<PointerUpEvent>(evt => { _innerImage.ReleaseMouse(); });
        }

        private bool IsPointerOnImage(Vector2 pointer)
        {
            var width = _innerImage.resolvedStyle.width;
            var height = _innerImage.resolvedStyle.height;
            var minSide = Mathf.Min(width, height);
            var widthReduction = (width - minSide) / 2;
            var heightReduction = (height - minSide) / 2;

            return (pointer.x >= widthReduction && pointer.x <= width - widthReduction &&
                    pointer.y >= heightReduction && pointer.y <= height - heightReduction);
        }

        private Vector2 ImageClampPosition(Vector2 position)
        {
            var width = _innerImage.resolvedStyle.width;
            var height = _innerImage.resolvedStyle.height;
            var minSide = Mathf.Min(width, height);
            var widthReduction = (width - minSide) / 2;
            var heightReduction = (height - minSide) / 2;

            var clampedWidth = Math.Clamp(position.x, widthReduction, width - widthReduction);
            var clampedHeight = Math.Clamp(position.y, heightReduction, height - heightReduction);
            return new Vector2(clampedWidth, clampedHeight);
        }

        private void SetImagePosition(Vector2 eventPos)
        {
            var position = ImageClampPosition(eventPos);
            this.Position = position;
            _innerImage.MarkDirtyRepaint();
        }

        private void ImageGenerateVisualContent(MeshGenerationContext context)
        {
            var rect = _innerImage.contentRect;
            var swh = Mathf.Min(rect.width, rect.height);
            var paint = context.painter2D;

            // Image position marker
            paint.BeginPath();
            {
                paint.Arc(Position, swh * ImageMarkerRadiusFactor - PositionMarkerOutlineThickness, 0, 360);

                paint.lineWidth = MarkerLineWidth + PositionMarkerOutlineThickness;
                paint.strokeColor = Color.black;
                paint.Stroke();

                paint.lineWidth = MarkerLineWidth;
                paint.strokeColor = Color.white;
                paint.Stroke();
            }
            paint.ClosePath();
        }

        #endregion

        #region Color Wheel

        private void SetWheelMouseCallbackEvents()
        {
            _colorWheel.RegisterCallback<PointerDownEvent>(evt =>
            {
                if (!IsPointerOnWheel(_colorWheel.WorldToLocal(evt.position)))
                    return;

                _colorWheel.CaptureMouse();
                SetWheelHue(_colorWheel.WorldToLocal(evt.position));
            });

            _colorWheel.RegisterCallback<PointerMoveEvent>(evt =>
            {
                if (_colorWheel.HasMouseCapture())
                {
                    SetWheelHue(_colorWheel.WorldToLocal(evt.position));
                }
            });

            _colorWheel.RegisterCallback<PointerUpEvent>(evt => { _colorWheel.ReleaseMouse(); });
        }

        private bool IsPointerOnWheel(Vector2 pointer)
        {
            var direction = pointer - _colorWheel.contentRect.center;

            var rect = _colorWheel.contentRect;
            var swh = Mathf.Min(rect.width, rect.height);
            var circleRadius = swh * ColorWheelCircleRadiusFactor;
            var gradientOffset = swh * ColorWheelThickness / 2;
            var length = direction.magnitude;

            return length < circleRadius + gradientOffset && length > circleRadius - gradientOffset;
        }

        private void SetWheelHue(Vector2 eventPosition)
        {
            var direction = eventPosition - _colorWheel.contentRect.center;

            Direction = direction;
        }

        private void OnGenerateVisualContent(MeshGenerationContext context)
        {
            var rect = _colorWheel.contentRect;
            var swh = Mathf.Min(rect.width, rect.height);
            var paint = context.painter2D;
            var circleRadius = swh * ColorWheelCircleRadiusFactor;
            var gradientWidth = swh * ColorWheelThickness;

            // Color ring
            paint.BeginPath();
            {
                paint.Arc(rect.center, circleRadius, 270f, -90f, ArcDirection.CounterClockwise);

                paint.lineWidth = gradientWidth;
                paint.strokeGradient = CircleGradient;
                paint.Stroke();
            }
            paint.ClosePath();

            // Hue position marker
            paint.BeginPath();
            {
                paint.Arc(
                    rect.center + Direction.normalized * circleRadius,
                    gradientWidth / 2 - PositionMarkerOutlineThickness, 0, 360
                );

                paint.lineWidth = MarkerLineWidth + PositionMarkerOutlineThickness;
                paint.strokeColor = Color.black;
                paint.Stroke();

                paint.lineWidth = MarkerLineWidth;
                paint.strokeColor = Color.white;
                paint.Stroke();
            }
            paint.ClosePath();
        }

        private void UpdateGradientTexture()
        {
            if (!_gradientTexture) return;
            var pixels = new Color[_gradientTexture.width * _gradientTexture.height];

            for (var x = 0; x < _gradientTexture.width; x++)
            {
                for (var y = 0; y < _gradientTexture.height; y++)
                {
                    pixels[x * _gradientTexture.width + y] = Color.HSVToRGB(
                        Hue,
                        (float)y / _gradientTexture.height,
                        (float)x / _gradientTexture.width
                    );
                }
            }

            _gradientTexture.SetPixels(pixels);
            _gradientTexture.Apply();
        }

        #endregion
    }

ColorPickerMenu.uss

:root {
}

#colorDisplay {
    height: 20%;
    max-height: 80px;
    justify-content: center;
    flex-direction: row;
    padding-top: 8px;
    padding-right: 8px;
    padding-bottom: 0;
    padding-left: 8px;
}

#colorDisplay > #active {
    width: 40%;
    background-color: rgb(255, 255, 255);
    border-top-right-radius: 15px;
    border-bottom-right-radius: 15px;
}

#colorDisplay > #past {
    width: 40%;
    background-color: rgb(255, 255, 255);
    border-top-left-radius: 15px;
    border-bottom-left-radius: 15px;
}

@jhocking
Copy link

jhocking commented Nov 20, 2025

Thanks LeoWillmann! But unless I'm misunderstanding how it works, you are missing a few namespace imports and a type alias at the top of UxmlColorPicker.cs. Basically edit your comment to put these four using statements on the top of your code.

For anyone else, here is the full script with that correction and with the top bar removed (that bar was the main thing ColorPickerMenu.css was for, so now you don't need that file). Create a file called UxmlColorPicker.cs and paste this into it. Now in UI Builder you can go to Library > Project > Custom Controls to drag this element into your layout. In your own scripts, you can write picker.OnColorChange += <your method> to subscribe to color change events.

using System;
using UnityEngine;
using UnityEngine.UIElements;
using Object = UnityEngine.Object;

[UxmlElement]
public partial class UxmlColorPicker : VisualElement
{
    private const string ColorDisplay = "colorDisplay";

    // runtime render constants:
    private const float PositionMarkerOutlineThickness = 2f;
    private const float ImageMarkerRadiusFactor = 0.05f;
    private const float MarkerLineWidth = 3f;
    private const float ColorWheelThickness = 0.1f;
    private const float ColorWheelCircleRadiusFactor = 0.425f;
    private const float ImageRectSizePercent = 50f;

    #region Properties

    private float _hue;

    [UxmlAttribute, Range(0f, 0.999f)]
    public float Hue
    {
        get => _hue;
        set
        {
            _hue = Math.Clamp(value, 0f, 1f);
            UpdateGradientTexture();
            OnColorUpdate();
            _colorWheel.MarkDirtyRepaint();
        }
    }

    private float _saturation = 1;

    [UxmlAttribute, Range(0f, 0.999f)]
    public float Saturation
    {
        get => _saturation;
        set
        {
            _saturation = Math.Clamp(value, 0f, 1f);
            _innerImage.MarkDirtyRepaint();
            OnColorUpdate();
        }
    }

    private float _brightness = 1;

    [UxmlAttribute, Range(0f, 0.999f)]
    public float Brightness
    {
        get => _brightness;
        set
        {
            _brightness = Math.Clamp(value, 0f, 1f);
            _innerImage.MarkDirtyRepaint();
            OnColorUpdate();
        }
    }

    public Color Color => Color.HSVToRGB(Hue, Saturation, Brightness);

    private Gradient CircleGradient { get; } = new()
    {
        mode = GradientMode.Blend,
        colorKeys = new GradientColorKey[]
        {
                new(Color.HSVToRGB(0, 1, 1), 0),
                new(Color.HSVToRGB(1 * 1f / 6f, 1, 1), 1 * 1f / 6f),
                new(Color.HSVToRGB(2 * 1f / 6f, 1, 1), 2 * 1f / 6f),
                new(Color.HSVToRGB(3 * 1f / 6f, 1, 1), 3 * 1f / 6f),
                new(Color.HSVToRGB(4 * 1f / 6f, 1, 1), 4 * 1f / 6f),
                new(Color.HSVToRGB(5 * 1f / 6f, 1, 1), 5 * 1f / 6f),
                new(Color.HSVToRGB(1, 1, 1), 1)
        }
    };

    /// <summary>
    /// Direction as a computed property. It converts the Hue value into a direction vector for the wheel marker
    /// and the other way around.
    /// </summary>
    private Vector2 Direction
    {
        get
        {
            var radians = (float)(Hue * 2 * Math.PI);
            return new Vector2(Mathf.Cos(radians), -Mathf.Sin(radians));
        }
        set
        {
            var degrees = Vector2.SignedAngle(Vector2.right, new Vector2(value.x, -value.y));
            Hue = (degrees + 360) % 360 / 360f;
        }
    }

    private Vector2 Position
    {
        get
        {
            var width = _innerImage.resolvedStyle.width;
            var height = _innerImage.resolvedStyle.height;
            var minSide = Mathf.Min(width, height);
            var widthReduction = (width - minSide) / 2;
            var heightReduction = (height - minSide) / 2;

            var posX = Saturation * minSide + widthReduction;
            var posY = (1 - Brightness) * minSide + heightReduction;

            return new Vector2(posX, posY);
        }
        set
        {
            var width = _innerImage.resolvedStyle.width;
            var height = _innerImage.resolvedStyle.height;
            var minSide = Mathf.Min(width, height);
            var widthReduction = (width - minSide) / 2;
            var heightReduction = (height - minSide) / 2;

            Saturation = (value.x - widthReduction) / (width - 2 * widthReduction);
            Brightness = 1 - (value.y - heightReduction) / (height - 2 * heightReduction);
        }
    }

    #endregion

    private readonly Texture2D _gradientTexture;
    private readonly VisualElement _colorWheel;

    private readonly Image _innerImage = new Image
    {
        style =
            {
                width = Length.Percent(ImageRectSizePercent),
                height = Length.Percent(ImageRectSizePercent),
                marginTop = Length.Auto(),
                marginRight = Length.Auto(),
                marginBottom = Length.Auto(),
                marginLeft = Length.Auto(),
            }
    };

    public event Action<Color> OnColorChange;

    public UxmlColorPicker()
    {
        style.flexGrow = 1;

        var colorDisplay = new VisualElement()
        {
            name = ColorDisplay
        };
        this.Add(colorDisplay);

        // Adding color wheel
        _colorWheel = new VisualElement
        {
            style =
                {
                    flexGrow = 1
                }
        };
        this.Add(_colorWheel);

        _colorWheel.generateVisualContent += OnGenerateVisualContent;
        SetWheelMouseCallbackEvents();

        // Creating gradient texture amd adding inner image
        _gradientTexture = new Texture2D(16, 16, TextureFormat.RGB24, false)
        {
            filterMode = FilterMode.Bilinear,
            hideFlags = HideFlags.HideAndDontSave,
            wrapMode = TextureWrapMode.Clamp
        };
        _innerImage.image = _gradientTexture;
        UpdateGradientTexture();
        _colorWheel.Add(_innerImage);

        _innerImage.generateVisualContent += ImageGenerateVisualContent;
        SetImageMouseCallbackEvents();

        OnColorUpdate();
    }

    ~UxmlColorPicker()
    {
        Object.Destroy(_gradientTexture);
    }

    private void OnColorUpdate()
    {
        OnColorChange?.Invoke(Color);
    }

    public void SetCurrentColor(Color color)
    {
        Color.RGBToHSV(color, out var hue, out var saturation, out var brightness);
        Hue = hue;
        Saturation = saturation;
        Brightness = brightness;
    }

    #region Image

    private void SetImageMouseCallbackEvents()
    {
        _innerImage.RegisterCallback<PointerDownEvent>(evt =>
        {
            if (!IsPointerOnImage(_innerImage.WorldToLocal(evt.position)))
                return;

            _innerImage.CaptureMouse();
            SetImagePosition(_innerImage.WorldToLocal(evt.position));
        });

        _innerImage.RegisterCallback<PointerMoveEvent>(evt =>
        {
            if (_innerImage.HasMouseCapture())
            {
                SetImagePosition(_innerImage.WorldToLocal(evt.position));
            }
        });

        _innerImage.RegisterCallback<PointerUpEvent>(evt => { _innerImage.ReleaseMouse(); });
    }

    private bool IsPointerOnImage(Vector2 pointer)
    {
        var width = _innerImage.resolvedStyle.width;
        var height = _innerImage.resolvedStyle.height;
        var minSide = Mathf.Min(width, height);
        var widthReduction = (width - minSide) / 2;
        var heightReduction = (height - minSide) / 2;

        return (pointer.x >= widthReduction && pointer.x <= width - widthReduction &&
                pointer.y >= heightReduction && pointer.y <= height - heightReduction);
    }

    private Vector2 ImageClampPosition(Vector2 position)
    {
        var width = _innerImage.resolvedStyle.width;
        var height = _innerImage.resolvedStyle.height;
        var minSide = Mathf.Min(width, height);
        var widthReduction = (width - minSide) / 2;
        var heightReduction = (height - minSide) / 2;

        var clampedWidth = Math.Clamp(position.x, widthReduction, width - widthReduction);
        var clampedHeight = Math.Clamp(position.y, heightReduction, height - heightReduction);
        return new Vector2(clampedWidth, clampedHeight);
    }

    private void SetImagePosition(Vector2 eventPos)
    {
        var position = ImageClampPosition(eventPos);
        this.Position = position;
        _innerImage.MarkDirtyRepaint();
    }

    private void ImageGenerateVisualContent(MeshGenerationContext context)
    {
        var rect = _innerImage.contentRect;
        var swh = Mathf.Min(rect.width, rect.height);
        var paint = context.painter2D;

        // Image position marker
        paint.BeginPath();
        {
            paint.Arc(Position, swh * ImageMarkerRadiusFactor - PositionMarkerOutlineThickness, 0, 360);

            paint.lineWidth = MarkerLineWidth + PositionMarkerOutlineThickness;
            paint.strokeColor = Color.black;
            paint.Stroke();

            paint.lineWidth = MarkerLineWidth;
            paint.strokeColor = Color.white;
            paint.Stroke();
        }
        paint.ClosePath();
    }

    #endregion

    #region Color Wheel

    private void SetWheelMouseCallbackEvents()
    {
        _colorWheel.RegisterCallback<PointerDownEvent>(evt =>
        {
            if (!IsPointerOnWheel(_colorWheel.WorldToLocal(evt.position)))
                return;

            _colorWheel.CaptureMouse();
            SetWheelHue(_colorWheel.WorldToLocal(evt.position));
        });

        _colorWheel.RegisterCallback<PointerMoveEvent>(evt =>
        {
            if (_colorWheel.HasMouseCapture())
            {
                SetWheelHue(_colorWheel.WorldToLocal(evt.position));
            }
        });

        _colorWheel.RegisterCallback<PointerUpEvent>(evt => { _colorWheel.ReleaseMouse(); });
    }

    private bool IsPointerOnWheel(Vector2 pointer)
    {
        var direction = pointer - _colorWheel.contentRect.center;

        var rect = _colorWheel.contentRect;
        var swh = Mathf.Min(rect.width, rect.height);
        var circleRadius = swh * ColorWheelCircleRadiusFactor;
        var gradientOffset = swh * ColorWheelThickness / 2;
        var length = direction.magnitude;

        return length < circleRadius + gradientOffset && length > circleRadius - gradientOffset;
    }

    private void SetWheelHue(Vector2 eventPosition)
    {
        var direction = eventPosition - _colorWheel.contentRect.center;

        Direction = direction;
    }

    private void OnGenerateVisualContent(MeshGenerationContext context)
    {
        var rect = _colorWheel.contentRect;
        var swh = Mathf.Min(rect.width, rect.height);
        var paint = context.painter2D;
        var circleRadius = swh * ColorWheelCircleRadiusFactor;
        var gradientWidth = swh * ColorWheelThickness;

        // Color ring
        paint.BeginPath();
        {
            paint.Arc(rect.center, circleRadius, 270f, -90f, ArcDirection.CounterClockwise);

            paint.lineWidth = gradientWidth;
            paint.strokeGradient = CircleGradient;
            paint.Stroke();
        }
        paint.ClosePath();

        // Hue position marker
        paint.BeginPath();
        {
            paint.Arc(
                rect.center + Direction.normalized * circleRadius,
                gradientWidth / 2 - PositionMarkerOutlineThickness, 0, 360
            );

            paint.lineWidth = MarkerLineWidth + PositionMarkerOutlineThickness;
            paint.strokeColor = Color.black;
            paint.Stroke();

            paint.lineWidth = MarkerLineWidth;
            paint.strokeColor = Color.white;
            paint.Stroke();
        }
        paint.ClosePath();
    }

    private void UpdateGradientTexture()
    {
        if (!_gradientTexture) return;
        var pixels = new Color[_gradientTexture.width * _gradientTexture.height];

        for (var x = 0; x < _gradientTexture.width; x++)
        {
            for (var y = 0; y < _gradientTexture.height; y++)
            {
                pixels[x * _gradientTexture.width + y] = Color.HSVToRGB(
                    Hue,
                    (float)y / _gradientTexture.height,
                    (float)x / _gradientTexture.width
                );
            }
        }

        _gradientTexture.SetPixels(pixels);
        _gradientTexture.Apply();
    }

    #endregion
}

@LeoWillmann
Copy link

Yes, I forgot to copy them in. My previous post has been updated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment