Выбор цветов HSL в WinRT

145

Ранее приводилось уже немало средств выбора цвета с ползунками, предназначенными для выбора красной, зеленой и синей составляющей. Это простая и удобная схема выбора цвета, потому что именно так цвет определяется для мониторов и в Windows Runtime.

Однако этот способ нельзя назвать естественным. Похоже, люди предпочитают схемы, использующие такие параметры, как оттенок, насыщенность и освещенность. Оттенок (hue) фактически соответствует цвету радуги, которыми, как считал Исаак Ньютон, были красный, оранжевый, желтый, зеленый, голубой, синий и фиолетовый. В «компьютерных» цветовых схемах диапазон оттенка проходит от красного к желтому, зеленому, светло-голубому, голубому, малиновому и снова возвращается к красному.

Оттенок объединяется со значением насыщенности (saturation). При максимальной насыщенности цвет получается наиболее ярким и живым, а при минимальной он становится серым. Далее учитывается показатель освещенности (lightness). Увеличение освещенности приводит к размывке цвета и его постепенному переходу в белый цвет при максимальном значении. С уменьшением освещенности от среднего значения цвет темнеет.

В частности, схема выбора цвета HSL (Hue-Saturation-Lightness) применяется в Windows Paint и Microsoft Word: на двумерной панели (аналог XYSlider) задается комбинация оттенка и насыщенности, а для задания освещенности используется обычный ползунок.

Чтобы имитировать эту схему выбора цвета, я создал структуру HSL для представления цветовых значений в формате HSL. Структура реализует функции преобразования между RGB и HSL:

using System;
using Windows.UI;

namespace WinRTTestApp
{
    public struct HSL
    {
        public HSL(byte hue, byte saturation, byte lightness) : this(hue * 360 / 255.0, saturation / 255.0, lightness / 255.0)
        { }

        // Оттенок от 0 до 360, насыщенность и освещенность от 0 до 1
        public HSL(double hue, double saturation, double lightness) : this()
        {
            this.Hue = hue;
            this.Saturation = saturation;
            this.Lightness = lightness;

            double chroma = saturation * (1 - Math.Abs(2 * lightness - 1));
            double h = hue / 60;
            double x = chroma * (1 - Math.Abs(h % 2 - 1));
            double r = 0, g = 0, b = 0;

            if (h < 1)
            {
                r = chroma;
                g = x;
            }
            else if (h < 2)
            {
                r = x;
                g = chroma;
            }
            else if (h < 3)
            {
                g = chroma;
                b = x;
            }
            else if (h < 4)
            {
                g = x;
                b = chroma;
            }
            else if (h < 5)
            {
                r = x;
                b = chroma;
            }
            else
            {
                r = chroma;
                b = x;
            }

            double m = lightness - chroma / 2;
            this.Color = Color.FromArgb(255, (byte)(255 * (r + m)),
                                             (byte)(255 * (g + m)),
                                             (byte)(255 * (b + m)));
        }

        public HSL(Color color) : this()
        {
            this.Color = color;

            double r = color.R / 255.0;
            double g = color.G / 255.0;
            double b = color.B / 255.0;
            double max = Math.Max(r, Math.Max(g, b));
            double min = Math.Min(r, Math.Min(g, b));

            double chroma = max - min;
            this.Lightness = (max + min) / 2;

            if (chroma != 0)
            {
                if (r == max)
                    this.Hue = 60 * (g - b) / chroma;

                else if (g == max)
                    this.Hue = 120 + 60 * (b - r) / chroma;

                else
                    this.Hue = 240 + 60 * (r - g) / chroma;

                this.Hue = (this.Hue + 360) % 360;

                if (this.Lightness < 0.5)
                    this.Saturation = chroma / (2 * this.Lightness);
                else
                    this.Saturation = chroma / (2 - 2 * this.Lightness);
            }
        }

        public double Hue { private set; get; }
        public double Lightness { private set; get; }
        public double Saturation { private set; get; }
        public Color Color { private set; get; }
    }
}

Обратите внимание на два разных конструктора: с аргументами byte и double. Для конкретного вызова конструктора HSL компилятор C# должен выбрать используемую версию. Первый вариант выбирается только в том случае, если все аргументы являются значениями byte. У третьего конструктора, преобразующего значение Color в HSL, такой неоднозначности не существует.

Представляя элемент управления XYSlider, я указал, что он был бы более полезным, если бы вместо событий Manipulation в нем использовались события Pointer. Здесь приводится обновленная версия. Из-за необходимости работы с событиями Pointer ей приходится отслеживать несколько пальцев, но она просто усредняет их для создания сводной позиции. В остальном элемент управления почти не изменился:

using System;
using System.Collections.Generic;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;

namespace WinRTTestApp
{
    public class XYSlider : ContentControl
    {
        Dictionary<uint, Point> pointerDic = new Dictionary<uint, Point>();
        FrameworkElement crossHairPart;
        ContentPresenter contentPresenter;

        static readonly DependencyProperty valueProperty =
                DependencyProperty.Register("Value",
                        typeof(Point), typeof(XYSlider),
                        new PropertyMetadata(new Point(), OnValueChanged));

        public event EventHandler<Point> ValueChanged;

        public XYSlider()
        {
            this.DefaultStyleKey = typeof(XYSlider);
        }

        public static DependencyProperty ValueProperty
        {
            get { return valueProperty; }
        }

        public Point Value
        {
            set { SetValue(ValueProperty, value); }
            get { return (Point)GetValue(ValueProperty); }
        }

        protected override void OnApplyTemplate()
        {
            // Отмена обработчиков событий
            if (contentPresenter != null)
            {
                contentPresenter.PointerPressed -= OnContentPresenterPointerPressed;
                contentPresenter.PointerMoved -= OnContentPresenterPointerMoved;
                contentPresenter.PointerReleased -= OnContentPresenterPointerReleased;
                contentPresenter.PointerCaptureLost -= OnContentPresenterPointerReleased;
                contentPresenter.SizeChanged -= OnContentPresenterSizeChanged;
            }

            // Получение новых частей
            crossHairPart = GetTemplateChild("CrossHairPart") as FrameworkElement;
            contentPresenter = GetTemplateChild("ContentPresenterPart") as ContentPresenter;

            // Назначение обработчиков событий
            if (contentPresenter != null)
            {
                contentPresenter.PointerPressed += OnContentPresenterPointerPressed;
                contentPresenter.PointerMoved += OnContentPresenterPointerMoved;
                contentPresenter.PointerReleased += OnContentPresenterPointerReleased;
                contentPresenter.PointerCaptureLost += OnContentPresenterPointerReleased;
                contentPresenter.SizeChanged += OnContentPresenterSizeChanged;
            }

            // Перекрестие должно быть прозрачным для касаний
            if (crossHairPart != null)
            {
                crossHairPart.IsHitTestVisible = false;
            }

            base.OnApplyTemplate();
        }

        private void OnContentPresenterPointerPressed(object sender, PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;
            Point point = e.GetCurrentPoint(contentPresenter).Position;
            pointerDic.Add(id, point);
            contentPresenter.CapturePointer(e.Pointer);

            RecalculateValue();
            e.Handled = true;
        }

        private void OnContentPresenterPointerMoved(object sender, PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;
            Point point = e.GetCurrentPoint(contentPresenter).Position;

            if (pointerDic.ContainsKey(id))
            {
                pointerDic[id] = point;
                RecalculateValue();
                e.Handled = true;
            }
        }

        private void OnContentPresenterPointerReleased(object sender, PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;

            if (pointerDic.ContainsKey(id))
            {
                pointerDic.Remove(id);
                RecalculateValue();
                e.Handled = true;
            }
        }

        private void OnContentPresenterSizeChanged(object sender, SizeChangedEventArgs e)
        {
            SetCrossHair();
        }

        private void RecalculateValue()
        {
            if (pointerDic.Values.Count > 0)
            {
                Point accumPoint = new Point();

                // Усреднение всех текущих точек касания
                foreach (Point point in pointerDic.Values)
                {
                    accumPoint.X += point.X;
                    accumPoint.Y += point.Y;
                }
                accumPoint.X /= pointerDic.Values.Count;
                accumPoint.Y /= pointerDic.Values.Count;

                RecalculateValue(accumPoint);
            }
        }

        private void RecalculateValue(Point absolutePoint)
        {
            double x = Math.Max(0, Math.Min(1, absolutePoint.X / contentPresenter.ActualWidth));
            double y = Math.Max(0, Math.Min(1, absolutePoint.Y / contentPresenter.ActualHeight));
            this.Value = new Point(x, y);
        }

        private void SetCrossHair()
        {
            if (contentPresenter != null && crossHairPart != null)
            {
                Canvas.SetLeft(crossHairPart, this.Value.X * contentPresenter.ActualWidth);
                Canvas.SetTop(crossHairPart, this.Value.Y * contentPresenter.ActualHeight);
            }
        }

        static void OnValueChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            ((XYSlider)obj).SetCrossHair();
            ((XYSlider)obj).OnValueChanged((Point)e.NewValue);
        }

        protected void OnValueChanged(Point value)
        {
            if (ValueChanged != null)
                ValueChanged(this, value);
        }
    }
}

Следующий шаг — построение элемента управления HslColorSelector. Этот класс, производный от UserControl, создает экземпляры XYSlider, Slider и TextBlock в файле XAML. Секция Resources определяет объекты ControlTemplate для XYSlider и Slider. Шаблон XYSlider существенно упрощен по сравнению с соответствующим элементом управления, созданным ранее, потому что я точно знал, какое визуальное оформление мне нужно, и не добавлял ничего лишнего:

<UserControl ...>

    <UserControl.Resources>
        <ControlTemplate x:Key="xySliderTemplate" TargetType="local:XYSlider">
            <Border>
                <Grid>
                    <ContentPresenter Name="ContentPresenterPart"
                                      Content="{TemplateBinding Content}" />
                    <Canvas>
                        <Path Name="CrossHairPart"
                              Fill="{TemplateBinding Foreground}"
                              Data="M 0 6 L -3 24 3 24 Z
                                    M 0 -6 L -3 -24 3 -24 Z
                                    M 6 0 L 24 -3 24 3 Z
                                    M -6 0 L -24 -2 -24 3 Z" />
                    </Canvas>
                </Grid>
            </Border>
        </ControlTemplate>

        <ControlTemplate x:Key="sliderTemplate" TargetType="Slider">
            <Grid>
                <Grid Name="HorizontalTemplate"
                      Background="Transparent"
                      Height="48">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition />
                        <ColumnDefinition Width="Auto" />
                        <ColumnDefinition Width="Auto" />
                    </Grid.ColumnDefinitions>

                    <Rectangle Name="HorizontalTrackRect"
                               Grid.Column="0"
                               Grid.ColumnSpan="3"
                               Fill="{TemplateBinding Background}"
                               Height="12"
                               VerticalAlignment="Top" />

                    <Thumb Name="HorizontalThumb"
                           Grid.Column="1"
                           DataContext="{TemplateBinding Value}">
                        <Thumb.Template>
                            <ControlTemplate TargetType="Thumb">
                                <Path Fill="{TemplateBinding Foreground}"
                                      Data="M 0 24 L -3 48 3 48 Z" />
                            </ControlTemplate>
                        </Thumb.Template>
                    </Thumb>

                    <Rectangle Name="HorizontalDecreaseRect"
                               Grid.Column="2"
                               Fill="Transparent" />
                </Grid>

                <Grid Name="VerticalTemplate"
                      Background="Transparent"
                      Width="48">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="*" />
                        <RowDefinition Height="Auto" />
                        <RowDefinition Height="Auto" />
                    </Grid.RowDefinitions>

                    <Rectangle Name="VerticalTrackRect"
                               Grid.Row="0"
                               Grid.RowSpan="3"
                               Fill="{TemplateBinding Background}"
                               Width="12"
                               HorizontalAlignment="Left" />

                    <Thumb Name="VerticalThumb"
                           Grid.Row="1"
                           DataContext="{TemplateBinding Value}">
                        <Thumb.Template>
                            <ControlTemplate TargetType="Thumb">
                                <Path Fill="{TemplateBinding Foreground}"
                                      Data="M 24 0 L 48 -3 48 3 Z" />
                            </ControlTemplate>
                        </Thumb.Template>
                    </Thumb>

                    <Rectangle Name="VerticalDecreaseRect"

                               Grid.Row="2"
                               Fill="Transparent" />
                </Grid>
            </Grid>
        </ControlTemplate>
    </UserControl.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <local:XYSlider x:Name="xySlider"
                        Grid.Row="0"
                        Template="{StaticResource xySliderTemplate}"
                        ValueChanged="OnXYSliderValueChanged">
            <Image Name="hsImage"
                   Stretch="None" />
        </local:XYSlider>

        <Slider Name="slider"
                Grid.Row="1"
                Orientation="Horizontal"
                Template="{StaticResource sliderTemplate}"
                Width="256"
                Margin="0 12"
                ValueChanged="OnSliderValueChanged">
            <Slider.Background>
                <LinearGradientBrush StartPoint="0 0" EndPoint="1 0">
                    <GradientStop Offset="0" Color="Black" />
                    <GradientStop x:Name="sliderGradientStop" Offset="0.5" />
                    <GradientStop Offset="1" Color="White" />
                </LinearGradientBrush>
            </Slider.Background>
        </Slider>

        <TextBlock Name="txtblk"
                   Grid.Row="2"                   
                   HorizontalAlignment="Center"
                   TextAlignment="Center"
                   FontSize="24" />
    </Grid>
</UserControl>

Следует отметить, что шаблон ControlTemplate для Slider окрашивает элемент, используя его свойство Background. Свойство Background самого элемента управления Slider определяется в конце файла XAML. Его значение представляет собой градиентную кисть LinearGradientBrush с переходом от черного к белому Центральный цвет задается в файле фонового кода и определяется комбинацией оттенка и насыщенности, выбранной пользователем в элементе управления XYSlider.

Файл фонового кода определяет свойство зависимости с именем Color типа Color. Конечно, для открытого свойства, которое должно использоваться в привязках, Color выглядит намного логичнее, чем открытое свойство типа HSL. Обработчик Loaded отвечает за создание растрового изображения для основной панели выбора оттенка/насыщенности. Он использует структуру HSL для преобразования значений HSL (со средним значением освещенности) в данные RGB для пикселов изображения:

using System;
using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.UI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Media.Imaging;

namespace WinRTTestApp
{
    public sealed partial class HslColorSelector : UserControl
    {
        bool doNotSetSliders = false;

        static readonly DependencyProperty colorProperty =
            DependencyProperty.Register("Color",
                typeof(Color),
                typeof(HslColorSelector),
                new PropertyMetadata(new Color(), OnColorChanged));

        public event EventHandler<Color> ColorChanged;

        public HslColorSelector()
        {
            this.InitializeComponent();
            Loaded += OnLoaded;
        }

        private async void OnLoaded(object sender, RoutedEventArgs e)
        {
            // Построение растрового изображения для панели оттенка/насыщенности
            WriteableBitmap bitmap = new WriteableBitmap(256, 256);
            byte[] pixels = new byte[4 * 256 * 256];
            int index = 0;

            for (int y = 0; y < 256; y++)
                for (int x = 0; x < 256; x++)
                {
                    HSL hsl = new HSL((byte)x, (byte)(255 - y), (byte)128);
                    Color clr = hsl.Color;

                    pixels[index++] = clr.B;
                    pixels[index++] = clr.G;
                    pixels[index++] = clr.R;
                    pixels[index++] = clr.A;
                }

            using (Stream pixelStream = bitmap.PixelBuffer.AsStream())
            {
                await pixelStream.WriteAsync(pixels, 0, pixels.Length);
            }
            bitmap.Invalidate();
            hsImage.Source = bitmap;
        }

        public static DependencyProperty ColorProperty
        {
            get { return colorProperty; }
        }

        public Color Color
        {
            set { SetValue(ColorProperty, value); }
            get { return (Color)GetValue(ColorProperty); }
        }

        // Обработчики событий для Slider
        private void OnXYSliderValueChanged(object sender, Point point)
        {
            HSL hsl = new HSL(360 * point.X, 1 - point.Y, 0.5);
            sliderGradientStop.Color = hsl.Color;
            SetColorFromSliders();
        }

        private void OnSliderValueChanged(object sender, RangeBaseValueChangedEventArgs e)
        {
            SetColorFromSliders();
        }

        private void SetColorFromSliders()
        {
            Point point = xySlider.Value;
            double value = slider.Value;
            HSL hsl = new HSL(360 * point.X, 1 - point.Y, value / 100);

            doNotSetSliders = true;
            this.Color = hsl.Color;
            doNotSetSliders = false;
        }

        // Обработчики изменений свойств
        static void OnColorChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            (obj as HslColorSelector).OnColorChanged((Color)e.NewValue);
        }

        protected void OnColorChanged(Color color)
        {
            HSL hsl = new HSL(color);

            if (!doNotSetSliders)
            {
                xySlider.Value = new Point(hsl.Hue / 360, 1 - hsl.Saturation);
                slider.Value = 100 * hsl.Lightness;
            }

            txtblk.Text = String.Format("RGB = ({0}, {1}, {2})",
                                        this.Color.R, this.Color.G, this.Color.B);

            if (ColorChanged != null)
                ColorChanged(this, color);
        }
    }
}

Когда новое свойство Color задается извне, обработчик OnColorChanged реагирует заданием значений XYSlider и Slider, а также выводом цветового значения RGB в элементе TextBlock. Когда пользователь изменяет значения XYSlider и Slider, задается новое значение свойства Color и вызывается метод OnColorChanged. Обычно рекурсивные вызовы обработчиков изменения свойств не создают проблем, но только не в данном случае, потому что циклическое преобразование — RGB в HSL и снова в RGB — не приводит в точности к исходному значению. Именно для этой цели и необходимо логическое поле doNotSetSliders при изменении свойства Color из пользовательского ввода.

Наконец, экземпляр HslColorSelector включается в ColorSettingDialog:

<UserControl ...>

    <Grid>
        <Border BorderBrush="Black" 
                Background="White"
                BorderThickness="3"
                Padding="32">
            <StackPanel>
                <Path Data="M 0 50 C 80 0 160 100 256 0"
                      StrokeEndLineCap="Round"
                      StrokeStartLineCap="Round"
                      StrokeThickness="{Binding Thickness}"
                      Margin="0 12">
                    <Path.Stroke>
                        <SolidColorBrush Color="{Binding Color}" />
                    </Path.Stroke>
                </Path>

                <local:HslColorSelector x:Name="hslColorSelector"
                                        Foreground="Black"
                                        Color="{Binding Path=Color, Mode=TwoWay}" />
            </StackPanel>
        </Border>
    </Grid>
</UserControl>

Не забудьте раскомментировать вызов диалогового окна с настройками цвета в файле MainPage.xaml.cs проекта FingerPaint:

// ...

private void colorbtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
    DisplayDialog(sender, new ColorSettingDialog());
}

// ...

Чтобы пользователь мог представить результат, элемент управления рисует образец линии с текущей толщиной и цветом:

Выбор цвета при рисовании

Работая с программой FingerPaint, вы, вероятно, заметите, что рисунок получается менее гладким, чем в предыдущих программах FingerPaint (это будет незаметно разве что на экранах с очень высоким разрешением). Для этого есть очень веская причина: при выводе графических объектов с применением Line, Polyline и Path выполняется сглаживание (antialiasing). Граница содержит цвета заливки и фона, и субъективно линии кажутся более плавными. Однако в библиотеке VectorDrawing сглаживание не применяется — пиксел либо окрашивается, либо нет.

Пройди тесты
Лучший чат для C# программистов