diff --git a/AGENTS.md b/AGENTS.md index cf195ca2..6e199c76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,5 +175,6 @@ Uses **SkiaSharp**, not ImageSharp (replaced in checkpoint 038). When working wi - `ColorPicker/BaseClasses/` — `ColorPickerBase`, `SkiaPickerBase`, `SliderBase`, `SliderStack`, `SliderStackWithAlpha` - `ColorPicker/Controls/` — `ColorWheel`, `ColorTriangle`, `ColorDisc`, `HslSlider`, `RgbSlider`, `AlphaSlider`, `LuminositySlider`, `DelegateSlider` +- `ColorPicker/Rendering/` — renderer contracts, semantic drawing contexts, gradients, and bundled renderer implementations - `ColorPicker/Behaviors/` — touch handling (`TouchBehavior`, `TouchActionEventArgs`) - `ColorPicker/Platforms/{Android,Windows}/` — touch behavior implementations per platform diff --git a/ColorPicker.UITests/PageObjects/LayoutTestPageObject.cs b/ColorPicker.UITests/PageObjects/LayoutTestPageObject.cs index 46c5f6d3..7b65c42c 100644 --- a/ColorPicker.UITests/PageObjects/LayoutTestPageObject.cs +++ b/ColorPicker.UITests/PageObjects/LayoutTestPageObject.cs @@ -1,7 +1,7 @@ +using ColorPicker.UITests.Infrastructure; using OpenQA.Selenium; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; -using ColorPicker.UITests.Infrastructure; namespace ColorPicker.UITests.PageObjects; @@ -176,6 +176,38 @@ public PixelImage CaptureCanvasImage(TimeSpan? timeout = null) return PixelImage.Load(path); } + public int WaitForWindowColorPixels( + Pixel expected, + int minimumCount, + TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + var count = 0; + while (DateTime.UtcNow < deadline) + { + using var image = Infrastructure.Screenshot.Capture(_driver); + count = CountExactPixels(image, expected); + if (count >= minimumCount) + return count; + Thread.Sleep(100); + } + return count; + } + + static int CountExactPixels(PixelImage image, Pixel expected) + { + var count = 0; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + if (image[x, y] == expected) + count++; + } + } + return count; + } + static string SafeText(AppiumElement e) { try { return e.Text ?? ""; } catch { return ""; } } static int TryReadSeq(string text) diff --git a/ColorPicker.UITests/Tests/RendererCustomizationTests.cs b/ColorPicker.UITests/Tests/RendererCustomizationTests.cs new file mode 100644 index 00000000..7a5ed9af --- /dev/null +++ b/ColorPicker.UITests/Tests/RendererCustomizationTests.cs @@ -0,0 +1,129 @@ +using ColorPicker.UITests.Infrastructure; + +namespace ColorPicker.UITests.Tests; + +[Collection(AppiumServerCollection.Name)] +public sealed class RendererCustomizationTests + : IClassFixture, IClassFixture +{ + readonly LayoutTestAppFixture _fixture; + + public RendererCustomizationTests(LayoutTestAppFixture fixture) + => _fixture = fixture; + + [Theory] + [InlineData("wheel:400x400:customindicator")] + [InlineData("triangle:400x400:customindicator")] + [InlineData("hsl:400x200:customindicator")] + public void Custom_Renderer_Can_Replace_Only_Indicators(string scenario) + { + _fixture.Page.Apply(scenario); + + using var image = _fixture.Page.CaptureCanvasImage(); + var customPixels = CountExactPixels(image, new Pixel(1, 2, 3, 255)); + + Assert.True( + customPixels >= 100, + $"Expected the custom indicator renderer in '{scenario}', found {customPixels} exact test-color pixels."); + } + + [Fact] + public void Classic_Renderer_Property_Change_Repaints_Existing_Control() + { + _fixture.Page.Apply("wheel:400x400"); + _fixture.Page.Apply("wheel:400x400:ifill=#010203"); + + var styledPixels = _fixture.Page.WaitForWindowColorPixels( + new Pixel(1, 2, 3, 255), + minimumCount: 100); + + Assert.True( + styledPixels >= 100, + $"Expected the configured classic indicator fill, found {styledPixels} exact pixels."); + } + + [Fact] + public void Renderer_Property_Can_Bind_To_Control_BindingContext() + { + _fixture.Page.Apply("wheel:400x400"); + _fixture.Page.Apply("wheel:400x400:bindfill=#010203"); + + using var image = _fixture.Page.CaptureCanvasImage(); + var boundPixels = CountExactPixels(image, new Pixel(1, 2, 3, 255)); + + Assert.True( + boundPixels >= 100, + $"Expected the bound renderer property, found {boundPixels} exact pixels."); + } + + [Fact] + public void Renderer_Binding_Survives_Removing_A_Shared_Child() + { + _fixture.Page.Apply("wheel:400x400:bindfill=#010203,removealphaafterattach"); + + using var image = _fixture.Page.CaptureCanvasImage(); + var boundPixels = CountExactPixels(image, new Pixel(1, 2, 3, 255)); + + Assert.True( + boundPixels >= 100, + $"Expected the renderer binding to survive child removal, found {boundPixels} exact pixels."); + } + + [Fact] + public void Renderer_Callback_Cannot_Leak_Canvas_State_To_Later_Elements() + { + _fixture.Page.Apply("wheel:400x400:bg=white"); + using var baseline = _fixture.Page.CaptureCanvasImage(); + + _fixture.Page.Apply("wheel:400x400:bg=white,overrestore"); + using var isolated = _fixture.Page.CaptureCanvasImage(); + + var differingPixels = CountDifferentPixels(baseline, isolated); + Assert.True( + differingPixels <= 10, + $"An over-restoring renderer shifted later elements ({differingPixels} pixels differ)."); + } + + [Fact] + public void Zero_Indicator_Thicknesses_Do_Not_Draw_Hairlines() + { + _fixture.Page.Apply("wheel:400x400:zerostrokestransparent"); + using var transparentStrokes = _fixture.Page.CaptureCanvasImage(); + + _fixture.Page.Apply("wheel:400x400:zerostrokes"); + using var coloredStrokes = _fixture.Page.CaptureCanvasImage(); + + Assert.Equal(0, CountDifferentPixels(transparentStrokes, coloredStrokes)); + } + + static int CountExactPixels(PixelImage image, Pixel expected) + { + var count = 0; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + if (image[x, y] == expected) + count++; + } + } + return count; + } + + static int CountDifferentPixels(PixelImage first, PixelImage second) + { + Assert.Equal(first.Width, second.Width); + Assert.Equal(first.Height, second.Height); + + var count = 0; + for (var y = 0; y < first.Height; y++) + { + for (var x = 0; x < first.Width; x++) + { + if (first[x, y] != second[x, y]) + count++; + } + } + return count; + } +} diff --git a/ColorPicker/BaseClasses/ColorPickerBase.cs b/ColorPicker/BaseClasses/ColorPickerBase.cs index a0be5706..a2967d3a 100644 --- a/ColorPicker/BaseClasses/ColorPickerBase.cs +++ b/ColorPicker/BaseClasses/ColorPickerBase.cs @@ -1,6 +1,7 @@ -namespace ColorPicker.BaseClasses; - using ColorPicker.Core.Connection; +using ColorPicker.Rendering; + +namespace ColorPicker.BaseClasses; /// /// ColorPicker base class @@ -27,6 +28,15 @@ public static readonly BindableProperty AttachedColorPickerProperty null, propertyChanged: HandleConnectedColorPicker); + public static readonly BindableProperty RendererProperty + = BindableProperty.Create(nameof(Renderer), + typeof(IColorPickerRenderer), + typeof(ColorPickerBase), + defaultValueCreator: _ => ColorPickerRenderers.CreateClassic(), + coerceValue: (_, value) => + value ?? ColorPickerRenderers.CreateClassic(), + propertyChanged: HandleRenderer); + // Backing store // /// The currently selected color. Bindable (two-way); changes raise @@ -46,6 +56,16 @@ public IColorPicker AttachedColorPicker set => SetValue(AttachedColorPickerProperty, value); } + /// + /// Renderer responsible for every visual element. Defaults to + /// . + /// + public IColorPickerRenderer Renderer + { + get => (IColorPickerRenderer)GetValue(RendererProperty); + set => SetValue(RendererProperty, value); + } + // Shared, cycle-safe connection graph. Every AttachedColorPicker link is an // undirected edge; all pickers in one connected component share a color. // @@ -58,6 +78,14 @@ public IColorPicker AttachedColorPicker // [ThreadStatic] static bool _propagatingColor; + bool _rendererSubscribed; + + protected ColorPickerBase() + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + } + // ColorPicker Subclass must implement to intercept SelectedColor change // protected abstract void OnSelectedColorChanging(Color color); @@ -83,6 +111,28 @@ protected virtual Size ArrangeLayoutChildren(Rect bounds) return bounds.Size; } + protected virtual void OnRendererChanged( + IColorPickerRenderer oldRenderer, + IColorPickerRenderer newRenderer) + { + } + + protected virtual void OnRendererInvalidated() + { + } + + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + RestoreRendererBindingContext(); + } + + protected void RestoreRendererBindingContext() + { + if (Renderer is BindableObject renderer) + SetInheritedBindingContext(renderer, BindingContext); + } + private class ColorPickerLayoutManager : ILayoutManager { readonly ColorPickerBase _layout; @@ -190,6 +240,49 @@ static void HandleConnectedColorPicker(BindableObject bindable, object oldValue, } } + static void HandleRenderer(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is not ColorPickerBase picker || + oldValue is not IColorPickerRenderer oldRenderer || + newValue is not IColorPickerRenderer newRenderer || + ReferenceEquals(oldRenderer, newRenderer)) + { + return; + } + + if (picker._rendererSubscribed) + { + oldRenderer.Invalidated -= picker.HandleRendererInvalidated; + newRenderer.Invalidated += picker.HandleRendererInvalidated; + } + if (newRenderer is BindableObject renderer) + SetInheritedBindingContext(renderer, picker.BindingContext); + picker.OnRendererChanged(oldRenderer, newRenderer); + picker.RestoreRendererBindingContext(); + picker.OnRendererInvalidated(); + } + + void HandleLoaded(object? sender, EventArgs e) + { + if (_rendererSubscribed) + return; + + Renderer.Invalidated += HandleRendererInvalidated; + _rendererSubscribed = true; + } + + void HandleUnloaded(object? sender, EventArgs e) + { + if (!_rendererSubscribed) + return; + + Renderer.Invalidated -= HandleRendererInvalidated; + _rendererSubscribed = false; + } + + void HandleRendererInvalidated(object? sender, EventArgs e) + => OnRendererInvalidated(); + /// /// Raised after changes, with the old and new color. /// diff --git a/ColorPicker/BaseClasses/SkiaPickerBase.cs b/ColorPicker/BaseClasses/SkiaPickerBase.cs index 7fc80f11..d61c3507 100644 --- a/ColorPicker/BaseClasses/SkiaPickerBase.cs +++ b/ColorPicker/BaseClasses/SkiaPickerBase.cs @@ -1,6 +1,5 @@ -namespace ColorPicker.BaseClasses; - using ColorPicker.Behaviors; +using ColorPicker.Rendering; #if WINDOWS using ColorPicker.Platforms.WinUI; #elif ANDROID @@ -9,6 +8,8 @@ namespace ColorPicker.BaseClasses; using SkiaSharp.Views.Maui.Controls; +namespace ColorPicker.BaseClasses; + /// /// Base class for the SkiaSharp-drawn pickers (disc, triangle, sliders). Owns the /// canvas, touch handling, and the indicator dot. @@ -103,6 +104,8 @@ public SkiaPickerBase() protected abstract void OnTouchActionReleased(TouchActionEventArgs args); protected abstract void OnTouchActionCancelled(TouchActionEventArgs args); + protected override void OnRendererInvalidated() => InvalidateSurface(); + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) { // Apply WidthRequest/HeightRequest as constraints @@ -156,22 +159,14 @@ void OnCanvasViewLoaded(object sender, EventArgs e) InvalidateSurface(); } - protected void PaintIndicator(SKCanvas canvas, SKPoint point) + protected void RenderElement(SKCanvas canvas, ColorPickerDrawingContext context) { - var paint = new SKPaint - { - IsAntialias = true, - Style = SKPaintStyle.Stroke - }; - - paint.Color = Colors.White.ToSKColor(); - paint.StrokeWidth = 2; - canvas.DrawCircle(point, GetIndicatorRadiusPixels() - 2, paint); - - paint.Color = Colors.Black.ToSKColor(); - paint.StrokeWidth = 1; - canvas.DrawCircle(point, GetIndicatorRadiusPixels() - 4, paint); - canvas.DrawCircle(point, GetIndicatorRadiusPixels(), paint); + using var recorder = new SKPictureRecorder(); + var bounds = new SKRect(0, 0, context.CanvasSize.Width, context.CanvasSize.Height); + var isolatedCanvas = recorder.BeginRecording(bounds); + Renderer.Render(isolatedCanvas, context); + using var picture = recorder.EndRecording(); + canvas.DrawPicture(picture); } void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e) diff --git a/ColorPicker/BaseClasses/SliderBase.cs b/ColorPicker/BaseClasses/SliderBase.cs index 92f421cd..e20a4f99 100644 --- a/ColorPicker/BaseClasses/SliderBase.cs +++ b/ColorPicker/BaseClasses/SliderBase.cs @@ -1,10 +1,18 @@ -namespace ColorPicker.BaseClasses; +using ColorPicker.Rendering; + +namespace ColorPicker.BaseClasses; public abstract class SliderBase { + protected SliderBase(SliderChannel channel) + { + Channel = channel; + } + + public SliderChannel Channel { get; } public bool PaintChessPattern { get; set; } public abstract float NewValue(Color color); public abstract Color GetNewColor(float newValue, Color oldColor); - public abstract SKPaint GetPaint(Color color, SKPoint startPoint, SKPoint endPoint); + public abstract ColorGradient GetGradient(Color color); } diff --git a/ColorPicker/BaseClasses/SliderStack.cs b/ColorPicker/BaseClasses/SliderStack.cs index 61642619..e5b65c17 100644 --- a/ColorPicker/BaseClasses/SliderStack.cs +++ b/ColorPicker/BaseClasses/SliderStack.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.BaseClasses; /// @@ -80,12 +82,20 @@ protected override void OnPaintSurface(SKCanvas canvas, int width, int height) { var canvasSize = new SKSize(width, height); UpdateLocations(SelectedColor, canvasSize); - canvas.Clear(); + RenderElement(canvas, new CanvasDrawingContext(canvasSize, SelectedColor)); foreach (var slider in _sliders) { - PaintSlider(canvas, slider, canvasSize); - PaintIndicator(canvas, slider.Location); + RenderSlider(canvas, slider, canvasSize); + RenderElement(canvas, new IndicatorDrawingContext( + canvasSize, + SelectedColor, + slider.Location, + GetIndicatorRadiusPixels(), + IndicatorRole.Slider, + slider.Slider.Channel, + new SKPoint(slider.Slider.NewValue(SelectedColor), 0.5F), + IsActive: slider.LocationProgressId is not null)); } } @@ -106,6 +116,7 @@ protected override void OnTouchActionPressed(TouchActionEventArgs args) UpdateColors(slider, canvasSize); } } + InvalidateSurface(); } protected override void OnTouchActionMoved(TouchActionEventArgs args) @@ -122,6 +133,7 @@ protected override void OnTouchActionMoved(TouchActionEventArgs args) UpdateColors(slider, canvasSize); } } + InvalidateSurface(); } protected override void OnTouchActionReleased(TouchActionEventArgs args) @@ -139,6 +151,7 @@ protected override void OnTouchActionReleased(TouchActionEventArgs args) UpdateColors(slider, canvasSize); } } + InvalidateSurface(); } protected override void OnTouchActionCancelled(TouchActionEventArgs args) @@ -150,6 +163,7 @@ protected override void OnTouchActionCancelled(TouchActionEventArgs args) slider.LocationProgressId = null; } } + InvalidateSurface(); } protected override SizeRequest GetMeasure(double widthConstraint, double heightConstraint) @@ -250,7 +264,7 @@ void UpdateColors(SliderBounds slider, SKSize canvasSize) InvalidateSurface(); } - void PaintSlider(SKCanvas canvas, SliderBounds slider, SKSize canvasSize) + void RenderSlider(SKCanvas canvas, SliderBounds slider, SKSize canvasSize) { var pickerRadiusPixels = GetIndicatorRadiusPixels(); var sliderTop = slider.GetSliderOffset(pickerRadiusPixels); @@ -269,71 +283,29 @@ void PaintSlider(SKCanvas canvas, SliderBounds slider, SKSize canvasSize) endPoint = new SKPoint(canvasSize.Width - EndMargin(), sliderTop); } - var paint = slider.Slider.GetPaint(SelectedColor, startPoint, endPoint); - paint.StrokeWidth = pickerRadiusPixels * 1.3F; - if (slider.Slider.PaintChessPattern) { - PaintChessPattern(canvas, slider, canvasSize); - } - - canvas.DrawLine(startPoint, endPoint, paint); - } - - void PaintChessPattern(SKCanvas canvas, SliderBounds slider, SKSize canvasSize) - { - var pickerRadiusPixels = GetIndicatorRadiusPixels(); - var sliderTop = slider.GetSliderOffset(pickerRadiusPixels); - var scale = pickerRadiusPixels / 3; - var path = new SKPath(); - - path.MoveTo(-1 * scale, -1 * scale); - path.LineTo(0 * scale, -1 * scale); - path.LineTo(0 * scale, 0 * scale); - path.LineTo(1 * scale, 0 * scale); - path.LineTo(1 * scale, 1 * scale); - path.LineTo(0 * scale, 1 * scale); - path.LineTo(0 * scale, 0 * scale); - path.LineTo(-1 * scale, 0 * scale); - path.LineTo(-1 * scale, -1 * scale); - - var matrix = SKMatrix.CreateScale(2 * scale, 2 * scale); - var paint = new SKPaint - { - PathEffect = SKPathEffect.Create2DPath(matrix, path), - Color = Colors.LightGray.ToSKColor(), - IsAntialias = true - }; - - SKRect patternRect; - SKRect clipRect; - SKRoundRect clipRoundRect; - - // Slider line center spans [pr*1.1, length - pr*1.1] but its round-cap stroke - // (thickness = 1.3*pr) extends 0.65*pr beyond each endpoint, so the visible - // pill spans [pr*0.45, length - pr*0.45]. Chess clip must match that pill. - float endInset = pickerRadiusPixels * 0.45F; - if (Vertical) - { - patternRect = new SKRect(sliderTop - pickerRadiusPixels, endInset - , sliderTop + pickerRadiusPixels, canvasSize.Height - endInset); - clipRect = new SKRect(sliderTop - (pickerRadiusPixels * 0.65f), endInset - , sliderTop + (pickerRadiusPixels * 0.65f), canvasSize.Height - endInset); - clipRoundRect = new SKRoundRect(clipRect, pickerRadiusPixels * 0.65f, pickerRadiusPixels * 0.65f); - } - else - { - patternRect = new SKRect(endInset, sliderTop - pickerRadiusPixels - , canvasSize.Width - endInset, sliderTop + pickerRadiusPixels); - clipRect = new SKRect(endInset, sliderTop - (pickerRadiusPixels * 0.65f) - , canvasSize.Width - endInset, sliderTop + (pickerRadiusPixels * 0.65f)); - clipRoundRect = new SKRoundRect(clipRect, pickerRadiusPixels * 0.65f, pickerRadiusPixels * 0.65f); + RenderElement(canvas, new SliderTransparencyDrawingContext( + canvasSize, + SelectedColor, + Vertical, + startPoint, + endPoint, + pickerRadiusPixels, + IndicatorPadding, + slider.Slider.Channel)); } - canvas.Save(); - canvas.ClipRoundRect(clipRoundRect); - canvas.DrawRect(patternRect, paint); - canvas.Restore(); + RenderElement(canvas, new SliderTrackDrawingContext( + canvasSize, + SelectedColor, + startPoint, + endPoint, + pickerRadiusPixels, + slider.Slider.NewValue(SelectedColor), + Vertical, + slider.Slider.Channel, + slider.Slider.GetGradient(SelectedColor))); } bool IsInSliderArea(SKPoint point, float slidersHeight) diff --git a/ColorPicker/Controls/AlphaSlider.cs b/ColorPicker/Controls/AlphaSlider.cs index d458e128..31e70ffb 100644 --- a/ColorPicker/Controls/AlphaSlider.cs +++ b/ColorPicker/Controls/AlphaSlider.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// A single alpha (opacity) slider. @@ -8,7 +10,8 @@ protected override IEnumerable GetSliders() { new DelegateSlider(AlphaSliderFactory.NewValueAlpha, AlphaSliderFactory.GetNewColorAlpha, - AlphaSliderFactory.GetPaintAlpha) + AlphaSliderFactory.GetGradientAlpha, + SliderChannel.Alpha) { PaintChessPattern = true } diff --git a/ColorPicker/Controls/AlphaSliderFactory.cs b/ColorPicker/Controls/AlphaSliderFactory.cs index f1a2c679..473f18e9 100644 --- a/ColorPicker/Controls/AlphaSliderFactory.cs +++ b/ColorPicker/Controls/AlphaSliderFactory.cs @@ -1,4 +1,5 @@ using ColorPicker.Core; +using ColorPicker.Rendering; namespace ColorPicker.Controls; @@ -12,24 +13,13 @@ public static Color GetNewColorAlpha(float newValue, Color oldColor) => Color.FromRgba(oldColor.Red, oldColor.Green, oldColor.Blue, _alpha.Write(oldColor.ToHsla(), newValue).A); - public static SKPaint GetPaintAlpha(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientAlpha(Color color) { var startColor = Color.FromRgba(color.Red, color.Green, color.Blue, 0).ToSKColor(); var endColor = Color.FromRgba(color.Red, color.Green, color.Blue, 1).ToSKColor(); - return GetPaint(startColor, endColor, startPoint, endPoint); + return GetGradient(startColor, endColor); } - public static SKPaint GetPaint(SKColor startColor, SKColor endColor, SKPoint startPoint, SKPoint endPoint) - { - var paint = new SKPaint() - { - IsAntialias = true, - Style = SKPaintStyle.Stroke, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round, - Shader = SKShader.CreateLinearGradient(startPoint, endPoint - , new SKColor[] { startColor, endColor }, new float[] { 0, 1 }, SKShaderTileMode.Clamp) - }; - return paint; - } + public static ColorGradient GetGradient(SKColor startColor, SKColor endColor) + => new(new[] { startColor, endColor }, new[] { 0F, 1F }); } diff --git a/ColorPicker/Controls/ColorDisc.cs b/ColorPicker/Controls/ColorDisc.cs index edad6eba..8530b92a 100644 --- a/ColorPicker/Controls/ColorDisc.cs +++ b/ColorPicker/Controls/ColorDisc.cs @@ -1,5 +1,6 @@ using ColorPicker.Core; using ColorPicker.Core.Interaction; +using ColorPicker.Rendering; namespace ColorPicker.Controls; @@ -17,7 +18,7 @@ public class ColorDisc : SkiaPickerBase long? _locationHsProgressId = null; long? _locationLProgressId = null; - readonly SKColor[] _sweepGradientColors = new SKColor[256]; + readonly ColorGradient _hueGradient; public static readonly BindableProperty ShowLuminosityRingProperty = BindableProperty.Create(nameof(ShowLuminosityRing), @@ -62,8 +63,10 @@ static void HandleCanvasBackgroundColor(BindableObject bindable, object oldValue /// public ColorDisc() { + var colors = new SKColor[256]; for (var i = 128; i >= -127; i--) - _sweepGradientColors[255 - (i + 127)] = Color.FromHsla((i < 0 ? 255 + i : i) / 255D, 1, 0.5).ToSKColor(); + colors[255 - (i + 127)] = Color.FromHsla((i < 0 ? 255 + i : i) / 255D, 1, 0.5).ToSKColor(); + _hueGradient = new ColorGradient(colors); } public override float GetIndicatorRadiusPixels() => GetIndicatorRadiusPixels(GetCanvasSize()); @@ -87,6 +90,7 @@ protected override void OnTouchActionPressed(TouchActionEventArgs args) _locationLProgressId = args.Id; WriteSelectedColor(_interaction.UpdateFromL(lUnit)); } + InvalidateSurface(); } protected override void OnTouchActionMoved(TouchActionEventArgs args) @@ -104,6 +108,7 @@ protected override void OnTouchActionMoved(TouchActionEventArgs args) var lUnit = PixelToUnit(point, canvasRadius, LRadius(canvasRadius)); WriteSelectedColor(_interaction.UpdateFromL(lUnit)); } + InvalidateSurface(); } protected override void OnTouchActionReleased(TouchActionEventArgs args) @@ -123,6 +128,7 @@ protected override void OnTouchActionReleased(TouchActionEventArgs args) var lUnit = PixelToUnit(point, canvasRadius, LRadius(canvasRadius)); WriteSelectedColor(_interaction.UpdateFromL(lUnit)); } + InvalidateSurface(); } protected override void OnTouchActionCancelled(TouchActionEventArgs args) @@ -131,11 +137,15 @@ protected override void OnTouchActionCancelled(TouchActionEventArgs args) _locationHsProgressId = null; else if (_locationLProgressId == args.Id) _locationLProgressId = null; + InvalidateSurface(); } protected override void OnPaintSurface(SKCanvas canvas, int width, int height) { + var canvasSize = new SKSize(width, height); var canvasRadius = GetSize() / 2F; + var center = new SKPoint(canvasRadius, canvasRadius); + var indicatorRadius = GetIndicatorRadiusPixels(); // Re-sync from SelectedColor each paint so the controller picks up // any external bindable-property change (incl. the initial default @@ -146,18 +156,53 @@ protected override void OnPaintSurface(SKCanvas canvas, int width, int height) var locationHs = UnitToPixel(_interaction.LocationHs, canvasRadius, HsRadius(canvasRadius)); var locationL = UnitToPixel(_interaction.LocationL, canvasRadius, LRadius(canvasRadius)); - canvas.Clear(); - PaintBackground(canvas, canvasRadius); + RenderElement(canvas, new CanvasDrawingContext(canvasSize, SelectedColor)); + RenderElement(canvas, new CircularBackgroundDrawingContext( + canvasSize, + SelectedColor, + center, + canvasRadius - indicatorRadius, + CanvasBackgroundColor, + CircularBackgroundRole.ColorDisc)); if (ShowLuminosityRing) { - PaintLGradient(canvas, canvasRadius); - PaintIndicator(canvas, locationL); + RenderElement(canvas, new LuminosityRingDrawingContext( + canvasSize, + SelectedColor, + center, + LRadius(canvasRadius), + indicatorRadius)); + RenderElement(canvas, new IndicatorDrawingContext( + canvasSize, + SelectedColor, + locationL, + indicatorRadius, + IndicatorRole.Luminosity, + NormalizedPosition: new SKPoint( + _interaction.LocationL.X, + _interaction.LocationL.Y), + AngleRadians: AngleFromCenter(locationL, center), + IsActive: _locationLProgressId is not null)); } - PaintColorSweepGradient(canvas, canvasRadius); - PaintGrayRadialGradient(canvas, canvasRadius); - PaintIndicator(canvas, locationHs); + RenderElement(canvas, new HueSaturationDiscDrawingContext( + canvasSize, + SelectedColor, + center, + HsRadius(canvasRadius), + _hueGradient)); + RenderElement(canvas, new IndicatorDrawingContext( + canvasSize, + SelectedColor, + locationHs, + indicatorRadius, + IndicatorRole.HueSaturation, + NormalizedPosition: new SKPoint( + _interaction.LocationHs.X, + _interaction.LocationHs.Y), + AngleRadians: AngleFromCenter(locationHs, center), + IsActive: _locationHsProgressId is not null)); } protected override void OnSelectedColorChanging(Color color) @@ -198,80 +243,6 @@ bool IsInLRing(UnitPoint lUnit, float canvasRadius) return _interaction.IsInL(lUnit, tolUnits); } - void PaintBackground(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - - var paint = new SKPaint - { - IsAntialias = true, - Color = CanvasBackgroundColor.ToSKColor() - }; - - canvas.DrawCircle(center, canvasRadius - GetIndicatorRadiusPixels(), paint); - } - - void PaintLGradient(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - - var colors = new List() - { - Color.FromHsla(SelectedColor.GetHue(), SelectedColor.GetSaturation(), 0.5).ToSKColor(), - Color.FromHsla(SelectedColor.GetHue(), SelectedColor.GetSaturation(), 1.0).ToSKColor(), - Color.FromHsla(SelectedColor.GetHue(), SelectedColor.GetSaturation(), 0.5).ToSKColor(), - Color.FromHsla(SelectedColor.GetHue(), SelectedColor.GetSaturation(), 0.0).ToSKColor(), - Color.FromHsla(SelectedColor.GetHue(), SelectedColor.GetSaturation(), 0.5).ToSKColor() - }; - - var shader = SKShader.CreateSweepGradient(center, colors.ToArray(), null); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Stroke, - StrokeWidth = GetIndicatorRadiusPixels() - }; - canvas.DrawCircle(center, LRadius(canvasRadius), paint); - } - - void PaintColorSweepGradient(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - - var shader = SKShader.CreateSweepGradient(center, _sweepGradientColors, null); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Fill - }; - canvas.DrawCircle(center, HsRadius(canvasRadius), paint); - } - - void PaintGrayRadialGradient(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - - var colors = new SKColor[] - { - SKColors.Gray, - SKColors.Transparent - }; - - var shader = SKShader.CreateRadialGradient(center, HsRadius(canvasRadius), colors, null, SKShaderTileMode.Clamp); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Fill - }; - canvas.DrawPaint(paint); - } - // Pixel ↔ unit-square coordinate bridge (per active-area radius). static UnitPoint PixelToUnit(SKPoint pixel, float canvasRadius, float activeRadius) => new((float)((pixel.X - canvasRadius) / (2.0 * activeRadius) + 0.5), @@ -290,4 +261,7 @@ float HsRadius(float canvasRadius) float LRadius(float canvasRadius) => canvasRadius - GetIndicatorRadiusPixels() - IndicatorPadding; + + static float AngleFromCenter(SKPoint point, SKPoint center) + => MathF.Atan2(point.Y - center.Y, point.X - center.X); } diff --git a/ColorPicker/Controls/ColorTriangle.cs b/ColorPicker/Controls/ColorTriangle.cs index 46a18572..d1ea1ab1 100644 --- a/ColorPicker/Controls/ColorTriangle.cs +++ b/ColorPicker/Controls/ColorTriangle.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// @@ -144,6 +146,7 @@ static void HandleIndicatorPadding(BindableObject bindable, object oldValue, obj /// public ColorTriangle() { + _area.Renderer = Renderer; _area.AttachedColorPicker = this; HorizontalOptions = LayoutOptions.Center; @@ -151,6 +154,7 @@ public ColorTriangle() Children.Add(_area); + _alphaSlider.Renderer = Renderer; _alphaSlider.AttachedColorPicker = this; UpdateAlphaSlider(ShowAlphaSlider); @@ -158,6 +162,14 @@ public ColorTriangle() protected override void OnSelectedColorChanging(Color color) { } + protected override void OnRendererChanged( + IColorPickerRenderer oldRenderer, + IColorPickerRenderer newRenderer) + { + _area.Renderer = newRenderer; + _alphaSlider.Renderer = newRenderer; + } + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) { if (WidthRequest >= 0) @@ -254,5 +266,6 @@ void UpdateAlphaSlider(bool show) { Children.Remove(_alphaSlider); } + RestoreRendererBindingContext(); } } diff --git a/ColorPicker/Controls/ColorTriangleArea.cs b/ColorPicker/Controls/ColorTriangleArea.cs index 15ea19ef..10f7986f 100644 --- a/ColorPicker/Controls/ColorTriangleArea.cs +++ b/ColorPicker/Controls/ColorTriangleArea.cs @@ -1,5 +1,6 @@ using ColorPicker.Core; using ColorPicker.Core.Interaction; +using ColorPicker.Rendering; namespace ColorPicker.Controls; @@ -19,7 +20,7 @@ TriangleAreaInteraction Interaction long? _locationSvProgressId = null; long? _locationHProgressId = null; - readonly SKColor[] _sweepGradientColors = new SKColor[256]; + readonly ColorGradient _hueGradient; public static readonly BindableProperty CanvasBackgroundColorProperty = BindableProperty.Create(nameof(CanvasBackgroundColor), @@ -69,10 +70,12 @@ public ColorTriangleArea() : base() HorizontalOptions = LayoutOptions.Center; VerticalOptions = LayoutOptions.Center; IndicatorRadiusScale = 0.035F; + var colors = new SKColor[256]; for (var i = 128; i >= -127; i--) { - _sweepGradientColors[255 - (i + 127)] = Color.FromHsla((i < 0 ? 255 + i : i) / 255D, 1, 0.5).ToSKColor(); + colors[255 - (i + 127)] = Color.FromHsla((i < 0 ? 255 + i : i) / 255D, 1, 0.5).ToSKColor(); } + _hueGradient = new ColorGradient(colors); } public override float GetIndicatorRadiusPixels(SKSize canvasSize) => GetSize(canvasSize) * IndicatorRadiusScale; @@ -99,6 +102,7 @@ protected override void OnTouchActionPressed(TouchActionEventArgs args) _locationHProgressId = args.Id; WriteSelectedColor(Interaction.UpdateFromH(hUnit)); } + InvalidateSurface(); } protected override void OnTouchActionMoved(TouchActionEventArgs args) @@ -119,6 +123,7 @@ protected override void OnTouchActionMoved(TouchActionEventArgs args) var hUnit = PixelToUnit(point, canvasRadius, HRadius(canvasRadius)); WriteSelectedColor(Interaction.UpdateFromH(hUnit)); } + InvalidateSurface(); } protected override void OnTouchActionReleased(TouchActionEventArgs args) @@ -141,6 +146,7 @@ protected override void OnTouchActionReleased(TouchActionEventArgs args) var hUnit = PixelToUnit(point, canvasRadius, HRadius(canvasRadius)); WriteSelectedColor(Interaction.UpdateFromH(hUnit)); } + InvalidateSurface(); } protected override void OnTouchActionCancelled(TouchActionEventArgs args) @@ -149,12 +155,16 @@ protected override void OnTouchActionCancelled(TouchActionEventArgs args) _locationSvProgressId = null; else if (_locationHProgressId == args.Id) _locationHProgressId = null; + InvalidateSurface(); } protected override void OnPaintSurface(SKCanvas canvas, int width, int height) { + var canvasSize = new SKSize(width, height); var canvasRadius = GetSize() / 2F; var (offX, offY) = GetDrawingOffset(); + var center = new SKPoint(canvasRadius + offX, canvasRadius + offY); + var indicatorRadius = GetIndicatorRadiusPixels(); // Re-sync from SelectedColor each paint so the controller picks up // any external bindable-property change (incl. the initial default @@ -164,26 +174,85 @@ protected override void OnPaintSurface(SKCanvas canvas, int width, int height) // Compute paint-time pixel positions of indicators from the // controller's unit-space locations. var locationSv = UnitToPixel(Interaction.LocationSv, canvasRadius, SvRadius(canvasRadius)); + locationSv.Offset(offX, offY); var hLocations = ComputeHueIndicatorPixels(canvasRadius); - - canvas.Clear(); - - canvas.Save(); - canvas.Translate(offX, offY); - - PaintBackground(canvas, canvasRadius); - PaintHGradient(canvas, canvasRadius); + hLocations.outer.Offset(offX, offY); + hLocations.inner.Offset(offX, offY); + hLocations.middle.Offset(offX, offY); + + var rotationRadians = RotateTriangleByHue + ? -(float)((2D * Math.PI * Interaction.LastHue) + (Math.PI / 2D)) + : 0; + var triangleRadius = SvRadius(canvasRadius); + var triangle = ComputeTriangleGeometry(center, triangleRadius, rotationRadians); + + RenderElement(canvas, new CanvasDrawingContext(canvasSize, SelectedColor)); + RenderElement(canvas, new CircularBackgroundDrawingContext( + canvasSize, + SelectedColor, + center, + canvasRadius, + CanvasBackgroundColor, + CircularBackgroundRole.ColorTriangle)); + RenderElement(canvas, new HueRingDrawingContext( + canvasSize, + SelectedColor, + center, + HRadius(canvasRadius), + indicatorRadius, + _hueGradient)); if (RotateTriangleByHue) - PaintLinePicker(canvas, hLocations.outer, hLocations.inner); + { + RenderElement(canvas, new HueLineIndicatorDrawingContext( + canvasSize, + SelectedColor, + hLocations.outer, + hLocations.inner, + AngleFromCenter(hLocations.middle, center), + _locationHProgressId is not null)); + } else - PaintIndicator(canvas, hLocations.middle); - - PaintSvTriangle(canvas, canvasRadius); - PaintIndicator(canvas, locationSv); + { + RenderElement(canvas, new IndicatorDrawingContext( + canvasSize, + SelectedColor, + hLocations.middle, + indicatorRadius, + IndicatorRole.Hue, + NormalizedPosition: new SKPoint( + Interaction.LocationH.X, + Interaction.LocationH.Y), + AngleRadians: AngleFromCenter(hLocations.middle, center), + IsActive: _locationHProgressId is not null)); + } - canvas.Restore(); + RenderElement(canvas, new SaturationValueTriangleDrawingContext( + canvasSize, + SelectedColor, + center, + triangleRadius, + Interaction.LastHue, + rotationRadians, + RotateTriangleByHue, + triangle.transform, + triangle.localHue, + triangle.localWhite, + triangle.localBlack, + triangle.hue, + triangle.white, + triangle.black)); + RenderElement(canvas, new IndicatorDrawingContext( + canvasSize, + SelectedColor, + locationSv, + indicatorRadius, + IndicatorRole.SaturationValue, + NormalizedPosition: new SKPoint( + Interaction.LocationSv.X, + Interaction.LocationSv.Y), + IsActive: _locationSvProgressId is not null)); } protected override void OnSelectedColorChanging(Color color) @@ -248,128 +317,9 @@ void WriteSelectedColor(HslaColor hsla) SelectedColor = hsla.ToMauiColor(); } - void PaintBackground(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - var paint = new SKPaint - { - IsAntialias = true, - Color = CanvasBackgroundColor.ToSKColor() - }; - - canvas.DrawCircle(center, canvasRadius, paint); - } - - void PaintHGradient(SKCanvas canvas, float canvasRadius) - { - var center = new SKPoint(canvasRadius, canvasRadius); - var shader = SKShader.CreateSweepGradient(center, _sweepGradientColors, null); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Stroke, - StrokeWidth = GetIndicatorRadiusPixels() * 2 - }; - canvas.DrawCircle(center, HRadius(canvasRadius), paint); - } - - void PaintSvTriangle(SKCanvas canvas, float canvasRadius) - { - var lastHue = Interaction.LastHue; - canvas.Save(); - - var rotationHue = SKMatrix.CreateRotation(-(float)((2D * Math.PI * lastHue) + (Math.PI / 2D)), - canvasRadius, canvasRadius); - - if (RotateTriangleByHue) - { - canvas.Concat(ref rotationHue); - } - - var point1 = new SKPoint(canvasRadius, canvasRadius - SvRadius(canvasRadius)); - var point2 = new SKPoint(canvasRadius + (_triangleSide * SvRadius(canvasRadius)) - , canvasRadius + (_triangleVerticalOffset * SvRadius(canvasRadius))); - - var point3 = new SKPoint(canvasRadius - (_triangleSide * SvRadius(canvasRadius)) - , canvasRadius + (_triangleVerticalOffset * SvRadius(canvasRadius))); - - using (var pathTriangle = new SKPath()) - { - pathTriangle.MoveTo(point1); - pathTriangle.LineTo(point2); - pathTriangle.LineTo(point3); - - canvas.ClipPath(pathTriangle, SKClipOperation.Intersect, true); - } - - canvas.Save(); - - var gradientRotation = SKMatrix.CreateRotation(-(float)Math.PI / 3F, point3.X, point3.Y); - canvas.Concat(ref gradientRotation); - - var shader = SKShader.CreateSweepGradient(point3, - new SKColor[] - { - Color.FromHsla(lastHue, 1, 0.5).ToSKColor(), - Colors.White.ToSKColor(), - Color.FromHsla(lastHue, 1, 0.5).ToSKColor() - }, - new float[] - { - 0F, 0.16666666666666F, 1F - }); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Fill - }; - - canvas.DrawCircle(point3, SvRadius(canvasRadius) * 2, paint); - - canvas.Restore(); - - var colors = new SKColor[] - { - SKColors.Black, - SKColors.Transparent - }; - - PaintGradient(canvas, canvasRadius, colors, point3); - - canvas.Restore(); - } - - void PaintGradient(SKCanvas canvas, float canvasRadius, SKColor[] colors, SKPoint centerGradient) - { - var center = new SKPoint(canvasRadius, canvasRadius); - var polar = ToPolar(new SKPoint(center.X - centerGradient.X, center.Y - centerGradient.Y)); - - polar = polar.WithRadius(polar.Radius * _triangleHeight); - - var p2 = FromPolar(polar); - p2.X += centerGradient.X; - p2.Y += centerGradient.Y; - - var shader = SKShader.CreateLinearGradient(centerGradient, p2, colors, null, SKShaderTileMode.Clamp); - - var paint = new SKPaint - { - IsAntialias = true, - Shader = shader, - Style = SKPaintStyle.Fill - }; - - canvas.DrawCircle(center, SvRadius(canvasRadius), paint); - } - // Triangle constants — used by the SV-triangle rendering path (vertices, // gradient stretch). The encoding/decoding math has moved to // ColorPicker.Core.SaturationValueTriangle which carries its own copies. - const float _triangleHeight = 1.5000001F; const float _triangleSide = 0.8660244F; const float _triangleVerticalOffset = 0.5000001F; @@ -404,21 +354,37 @@ static SKPoint FromPolar(PolarPoint point) // (the consistent border gap), matching the disc/wheel. float HRadius(float canvasRadius) => canvasRadius - GetIndicatorRadiusPixels() - IndicatorPadding; - void PaintLinePicker(SKCanvas canvas, SKPoint outer, SKPoint inner) + static ( + SKMatrix transform, + SKPoint localHue, + SKPoint localWhite, + SKPoint localBlack, + SKPoint hue, + SKPoint white, + SKPoint black) ComputeTriangleGeometry( + SKPoint center, + float radius, + float rotationRadians) { - var paint = new SKPaint - { - IsAntialias = true, - Style = SKPaintStyle.Stroke - }; - - paint.Color = Colors.Black.ToSKColor(); - paint.StrokeWidth = 4; - - using var pathTriangle = new SKPath(); - pathTriangle.MoveTo(outer); - pathTriangle.LineTo(inner); - - canvas.DrawPath(pathTriangle, paint); + var localHue = new SKPoint(center.X, center.Y - radius); + var localWhite = new SKPoint( + center.X + (_triangleSide * radius), + center.Y + (_triangleVerticalOffset * radius)); + var localBlack = new SKPoint( + center.X - (_triangleSide * radius), + center.Y + (_triangleVerticalOffset * radius)); + var transform = SKMatrix.CreateRotation(rotationRadians, center.X, center.Y); + + return ( + transform, + localHue, + localWhite, + localBlack, + transform.MapPoint(localHue), + transform.MapPoint(localWhite), + transform.MapPoint(localBlack)); } + + static float AngleFromCenter(SKPoint point, SKPoint center) + => MathF.Atan2(point.Y - center.Y, point.X - center.X); } diff --git a/ColorPicker/Controls/ColorWheel.cs b/ColorPicker/Controls/ColorWheel.cs index f1b26ca9..3b467630 100644 --- a/ColorPicker/Controls/ColorWheel.cs +++ b/ColorPicker/Controls/ColorWheel.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// @@ -167,6 +169,7 @@ static void HandleIndicatorPadding(BindableObject bindable, object oldValue, obj /// public ColorWheel() { + _disc.Renderer = Renderer; _disc.AttachedColorPicker = this; HorizontalOptions = LayoutOptions.Center; @@ -174,7 +177,9 @@ public ColorWheel() Children.Add(_disc); + _alphaSlider.Renderer = Renderer; _alphaSlider.AttachedColorPicker = this; + _luminositySlider.Renderer = Renderer; _luminositySlider.AttachedColorPicker = this; UpdateAlphaSlider(ShowAlphaSlider); @@ -183,6 +188,15 @@ public ColorWheel() protected override void OnSelectedColorChanging(Color color) { } + protected override void OnRendererChanged( + IColorPickerRenderer oldRenderer, + IColorPickerRenderer newRenderer) + { + _disc.Renderer = newRenderer; + _alphaSlider.Renderer = newRenderer; + _luminositySlider.Renderer = newRenderer; + } + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) { // Apply WidthRequest/HeightRequest as constraints @@ -300,6 +314,7 @@ void UpdateAlphaSlider(bool show) Children.Add(_alphaSlider); else Children.Remove(_alphaSlider); + RestoreRendererBindingContext(); } void UpdateLuminositySlider(bool show) @@ -308,5 +323,6 @@ void UpdateLuminositySlider(bool show) Children.Add(_luminositySlider); else Children.Remove(_luminositySlider); + RestoreRendererBindingContext(); } } diff --git a/ColorPicker/Controls/DelegateSlider.cs b/ColorPicker/Controls/DelegateSlider.cs index 33f4da41..3b265519 100644 --- a/ColorPicker/Controls/DelegateSlider.cs +++ b/ColorPicker/Controls/DelegateSlider.cs @@ -1,26 +1,30 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// /// A single slider whose channel behavior — reading the value from a color, writing -/// a new color, and painting the track gradient — is supplied as delegates, so custom +/// a new color, and describing the track gradient — is supplied as delegates, so custom /// single-channel sliders can be built without a dedicated subclass. /// public class DelegateSlider : SliderBase { - readonly Func _newValue; - readonly Func _getNewColor; - readonly Func _getPaint; + readonly Func _newValue; + readonly Func _getNewColor; + readonly Func _getGradient; public DelegateSlider(Func newValue, Func getNewColor, - Func getPaint) + Func getGradient, + SliderChannel channel = SliderChannel.Custom) + : base(channel) { _newValue = newValue; _getNewColor = getNewColor; - _getPaint = getPaint; + _getGradient = getGradient; } public override Color GetNewColor(float newValue, Color oldColor) => _getNewColor(newValue, oldColor); - public override SKPaint GetPaint(Color color, SKPoint startPoint, SKPoint endPoint) => _getPaint(color, startPoint, endPoint); + public override ColorGradient GetGradient(Color color) => _getGradient(color); public override float NewValue(Color color) => _newValue(color); } diff --git a/ColorPicker/Controls/HslSlider.cs b/ColorPicker/Controls/HslSlider.cs index 433320a4..61bdd4bc 100644 --- a/ColorPicker/Controls/HslSlider.cs +++ b/ColorPicker/Controls/HslSlider.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// A stack of Hue, Saturation and Lightness sliders, with an optional alpha slider. @@ -9,22 +11,26 @@ protected override IEnumerable GetSliders() { new DelegateSlider(HslSliderFactory.NewValueH, HslSliderFactory.GetNewColorH, - HslSliderFactory.GetPaintH), + HslSliderFactory.GetGradientH, + SliderChannel.Hue), new DelegateSlider(HslSliderFactory.NewValueS, HslSliderFactory.GetNewColorS, - HslSliderFactory.GetPaintS), + HslSliderFactory.GetGradientS, + SliderChannel.Saturation), new DelegateSlider(HslSliderFactory.NewValueL, HslSliderFactory.GetNewColorL, - HslSliderFactory.GetPaintL) + HslSliderFactory.GetGradientL, + SliderChannel.Luminosity) }; if (ShowAlphaSlider) { var slider = new DelegateSlider(AlphaSliderFactory.NewValueAlpha, AlphaSliderFactory.GetNewColorAlpha, - AlphaSliderFactory.GetPaintAlpha) + AlphaSliderFactory.GetGradientAlpha, + SliderChannel.Alpha) { PaintChessPattern = true }; diff --git a/ColorPicker/Controls/HslSliderFactory.cs b/ColorPicker/Controls/HslSliderFactory.cs index 7edd5b71..8220d9a9 100644 --- a/ColorPicker/Controls/HslSliderFactory.cs +++ b/ColorPicker/Controls/HslSliderFactory.cs @@ -1,4 +1,5 @@ using ColorPicker.Core; +using ColorPicker.Rendering; namespace ColorPicker.Controls; @@ -7,6 +8,7 @@ public static class HslSliderFactory static readonly HueSlider _hue = new(); static readonly SaturationSlider _sat = new(); static readonly Core.LuminositySlider _lum = new(); + static readonly ColorGradient _hueGradient = CreateHueGradient(); public static float NewValueH(Color color) => (float)_hue.Read(color.ToHsla()); public static float NewValueS(Color color) => (float)_sat.Read(color.ToHsla()); @@ -21,7 +23,9 @@ public static Color GetNewColorS(float newValue, Color oldColor) public static Color GetNewColorL(float newValue, Color oldColor) => _lum.Write(oldColor.ToHsla(), newValue).ToMauiColor(); - public static SKPaint GetPaintH(Color _, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientH(Color _) => _hueGradient; + + static ColorGradient CreateHueGradient() { var colors = new List(); @@ -37,10 +41,10 @@ public static SKPaint GetPaintH(Color _, SKPoint startPoint, SKPoint endPoint) colorPos.Add(i / 255F); } - return GetPaint(colors.ToArray(), colorPos.ToArray(), startPoint, endPoint); + return new ColorGradient(colors, colorPos); } - public static SKPaint GetPaintS(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientS(Color color) { var colors = new SKColor[] { @@ -49,10 +53,10 @@ public static SKPaint GetPaintS(Color color, SKPoint startPoint, SKPoint endPoin }; var colorPos = new float[] { 0F, 1F }; - return GetPaint(colors, colorPos, startPoint, endPoint); + return new ColorGradient(colors, colorPos); } - public static SKPaint GetPaintL(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientL(Color color) { var colors = new SKColor[] { @@ -62,21 +66,6 @@ public static SKPaint GetPaintL(Color color, SKPoint startPoint, SKPoint endPoin }; var colorPos = new float[] { 0F, 0.5F, 1F }; - return GetPaint(colors, colorPos, startPoint, endPoint); - } - - public static SKPaint GetPaint(SKColor[] colors, float[] colorPos, SKPoint startPoint, SKPoint endPoint) - { - var shader = SKShader.CreateLinearGradient(startPoint, endPoint, - colors, colorPos, SKShaderTileMode.Clamp); - var paint = new SKPaint() - { - IsAntialias = true, - Style = SKPaintStyle.Stroke, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round, - Shader = shader - }; - return paint; + return new ColorGradient(colors, colorPos); } } diff --git a/ColorPicker/Controls/LuminositySlider.cs b/ColorPicker/Controls/LuminositySlider.cs index 614443e4..250eb98e 100644 --- a/ColorPicker/Controls/LuminositySlider.cs +++ b/ColorPicker/Controls/LuminositySlider.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// A single luminosity (lightness) slider. @@ -8,6 +10,7 @@ protected override IEnumerable GetSliders() { new DelegateSlider(HslSliderFactory.NewValueL, HslSliderFactory.GetNewColorL, - HslSliderFactory.GetPaintL) + HslSliderFactory.GetGradientL, + SliderChannel.Luminosity) }; } diff --git a/ColorPicker/Controls/RgbSlider.cs b/ColorPicker/Controls/RgbSlider.cs index 09c4f354..38f98164 100644 --- a/ColorPicker/Controls/RgbSlider.cs +++ b/ColorPicker/Controls/RgbSlider.cs @@ -1,3 +1,5 @@ +using ColorPicker.Rendering; + namespace ColorPicker.Controls; /// A stack of Red, Green and Blue sliders, with an optional alpha slider. @@ -9,22 +11,26 @@ protected override IEnumerable GetSliders() { new DelegateSlider(RgbSliderFactory.NewValueR, RgbSliderFactory.GetNewColorR, - RgbSliderFactory.GetPaintR), + RgbSliderFactory.GetGradientR, + SliderChannel.Red), new DelegateSlider(RgbSliderFactory.NewValueG, RgbSliderFactory.GetNewColorG, - RgbSliderFactory.GetPaintG), + RgbSliderFactory.GetGradientG, + SliderChannel.Green), new DelegateSlider(RgbSliderFactory.NewValueB, RgbSliderFactory.GetNewColorB, - RgbSliderFactory.GetPaintB) + RgbSliderFactory.GetGradientB, + SliderChannel.Blue) }; if (ShowAlphaSlider) { var slider = new DelegateSlider(AlphaSliderFactory.NewValueAlpha, AlphaSliderFactory.GetNewColorAlpha, - AlphaSliderFactory.GetPaintAlpha) + AlphaSliderFactory.GetGradientAlpha, + SliderChannel.Alpha) { PaintChessPattern = true }; diff --git a/ColorPicker/Controls/RgbSliderFactory.cs b/ColorPicker/Controls/RgbSliderFactory.cs index 472e9558..1d30747a 100644 --- a/ColorPicker/Controls/RgbSliderFactory.cs +++ b/ColorPicker/Controls/RgbSliderFactory.cs @@ -1,4 +1,5 @@ using ColorPicker.Core; +using ColorPicker.Rendering; namespace ColorPicker.Controls; @@ -8,9 +9,9 @@ public static class RgbSliderFactory static readonly GreenSlider _green = new(); static readonly BlueSlider _blue = new(); - public static float NewValueR(Color color) => (float)_red .Read(color.ToRgba()); + public static float NewValueR(Color color) => (float)_red.Read(color.ToRgba()); public static float NewValueG(Color color) => (float)_green.Read(color.ToRgba()); - public static float NewValueB(Color color) => (float)_blue .Read(color.ToRgba()); + public static float NewValueB(Color color) => (float)_blue.Read(color.ToRgba()); public static Color GetNewColorR(float newValue, Color oldColor) => _red.Write(oldColor.ToRgba(), newValue).WithA(oldColor.Alpha).ToMauiColor(); @@ -21,38 +22,27 @@ public static Color GetNewColorG(float newValue, Color oldColor) public static Color GetNewColorB(float newValue, Color oldColor) => _blue.Write(oldColor.ToRgba(), newValue).WithA(oldColor.Alpha).ToMauiColor(); - public static SKPaint GetPaintR(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientR(Color color) { var startColor = new Color(0, color.Green, color.Blue).ToSKColor(); var endColor = new Color(1, color.Green, color.Blue).ToSKColor(); - return GetPaint(startColor, endColor, startPoint, endPoint); + return GetGradient(startColor, endColor); } - public static SKPaint GetPaintG(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientG(Color color) { var startColor = new Color(color.Red, 0, color.Blue).ToSKColor(); var endColor = new Color(color.Red, 1, color.Blue).ToSKColor(); - return GetPaint(startColor, endColor, startPoint, endPoint); + return GetGradient(startColor, endColor); } - public static SKPaint GetPaintB(Color color, SKPoint startPoint, SKPoint endPoint) + public static ColorGradient GetGradientB(Color color) { var startColor = new Color(color.Red, color.Green, 0).ToSKColor(); var endColor = new Color(color.Red, color.Green, 1).ToSKColor(); - return GetPaint(startColor, endColor, startPoint, endPoint); + return GetGradient(startColor, endColor); } - public static SKPaint GetPaint(SKColor startColor, SKColor endColor, SKPoint startPoint, SKPoint endPoint) - { - var paint = new SKPaint() - { - IsAntialias = true, - Style = SKPaintStyle.Stroke, - StrokeCap = SKStrokeCap.Round, - StrokeJoin = SKStrokeJoin.Round, - Shader = SKShader.CreateLinearGradient(startPoint, endPoint, new SKColor[] { startColor, endColor }, - new float[] { 0, 1 }, SKShaderTileMode.Clamp) - }; - return paint; - } + public static ColorGradient GetGradient(SKColor startColor, SKColor endColor) + => new(new[] { startColor, endColor }, new[] { 0F, 1F }); } diff --git a/ColorPicker/Rendering/ClassicColorPickerRenderer.cs b/ColorPicker/Rendering/ClassicColorPickerRenderer.cs new file mode 100644 index 00000000..18d6c94c --- /dev/null +++ b/ColorPicker/Rendering/ClassicColorPickerRenderer.cs @@ -0,0 +1,629 @@ +namespace ColorPicker.Rendering; + +/// +/// The original ColorPicker.Maui appearance, implemented as a reusable and +/// extensible renderer. Override individual drawing methods for selective +/// customization. +/// +public class ClassicColorPickerRenderer : ColorPickerRenderer +{ + const float TriangleHeight = 1.5000001F; + + public static readonly BindableProperty IsAntialiasProperty = + BindableProperty.Create(nameof(IsAntialias), typeof(bool), typeof(ClassicColorPickerRenderer), true); + + public static readonly BindableProperty IndicatorOuterColorProperty = + BindableProperty.Create(nameof(IndicatorOuterColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.Black); + + public static readonly BindableProperty IndicatorFillColorProperty = + BindableProperty.Create(nameof(IndicatorFillColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.Transparent); + + public static readonly BindableProperty IndicatorOuterThicknessProperty = + BindableProperty.Create( + nameof(IndicatorOuterThickness), + typeof(float), + typeof(ClassicColorPickerRenderer), + 1F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty IndicatorHighlightColorProperty = + BindableProperty.Create(nameof(IndicatorHighlightColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.White); + + public static readonly BindableProperty IndicatorHighlightThicknessProperty = + BindableProperty.Create( + nameof(IndicatorHighlightThickness), + typeof(float), + typeof(ClassicColorPickerRenderer), + 2F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty IndicatorHighlightInsetProperty = + BindableProperty.Create( + nameof(IndicatorHighlightInset), + typeof(float), + typeof(ClassicColorPickerRenderer), + 2F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty IndicatorInnerColorProperty = + BindableProperty.Create(nameof(IndicatorInnerColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.Black); + + public static readonly BindableProperty IndicatorInnerThicknessProperty = + BindableProperty.Create( + nameof(IndicatorInnerThickness), + typeof(float), + typeof(ClassicColorPickerRenderer), + 1F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty IndicatorInnerInsetProperty = + BindableProperty.Create( + nameof(IndicatorInnerInset), + typeof(float), + typeof(ClassicColorPickerRenderer), + 4F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty SliderTrackThicknessScaleProperty = + BindableProperty.Create( + nameof(SliderTrackThicknessScale), + typeof(float), + typeof(ClassicColorPickerRenderer), + 1.3F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty SliderStrokeCapProperty = + BindableProperty.Create(nameof(SliderStrokeCap), typeof(SKStrokeCap), typeof(ClassicColorPickerRenderer), SKStrokeCap.Round); + + public static readonly BindableProperty SliderStrokeJoinProperty = + BindableProperty.Create(nameof(SliderStrokeJoin), typeof(SKStrokeJoin), typeof(ClassicColorPickerRenderer), SKStrokeJoin.Round); + + public static readonly BindableProperty AlphaPatternLightColorProperty = + BindableProperty.Create(nameof(AlphaPatternLightColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.LightGray); + + public static readonly BindableProperty AlphaPatternDarkColorProperty = + BindableProperty.Create(nameof(AlphaPatternDarkColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.Transparent); + + public static readonly BindableProperty AlphaPatternCellSizeScaleProperty = + BindableProperty.Create( + nameof(AlphaPatternCellSizeScale), + typeof(float), + typeof(ClassicColorPickerRenderer), + 1F / 3F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty HueRingThicknessScaleProperty = + BindableProperty.Create( + nameof(HueRingThicknessScale), + typeof(float), + typeof(ClassicColorPickerRenderer), + 2F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty LuminosityRingThicknessScaleProperty = + BindableProperty.Create( + nameof(LuminosityRingThicknessScale), + typeof(float), + typeof(ClassicColorPickerRenderer), + 1F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty TriangleHueIndicatorColorProperty = + BindableProperty.Create(nameof(TriangleHueIndicatorColor), typeof(Color), typeof(ClassicColorPickerRenderer), Colors.Black); + + public static readonly BindableProperty TriangleHueIndicatorThicknessProperty = + BindableProperty.Create( + nameof(TriangleHueIndicatorThickness), + typeof(float), + typeof(ClassicColorPickerRenderer), + 4F, + validateValue: IsNonNegativeFloat); + + public static readonly BindableProperty TriangleHueIndicatorStrokeCapProperty = + BindableProperty.Create( + nameof(TriangleHueIndicatorStrokeCap), + typeof(SKStrokeCap), + typeof(ClassicColorPickerRenderer), + SKStrokeCap.Butt); + + /// Whether classic drawing uses antialiasing. Default true. + public bool IsAntialias + { + get => (bool)GetValue(IsAntialiasProperty); + set => SetValue(IsAntialiasProperty, value); + } + + /// Color of the indicator's outer outline. Default black. + public Color IndicatorOuterColor + { + get => (Color)GetValue(IndicatorOuterColorProperty); + set => SetValue(IndicatorOuterColorProperty, value); + } + + /// Indicator fill color. Default transparent. + public Color IndicatorFillColor + { + get => (Color)GetValue(IndicatorFillColorProperty); + set => SetValue(IndicatorFillColorProperty, value); + } + + /// Thickness of the indicator's outer outline in pixels. Default 1. + public float IndicatorOuterThickness + { + get => (float)GetValue(IndicatorOuterThicknessProperty); + set => SetValue(IndicatorOuterThicknessProperty, value); + } + + /// Color of the indicator's highlighted middle outline. Default white. + public Color IndicatorHighlightColor + { + get => (Color)GetValue(IndicatorHighlightColorProperty); + set => SetValue(IndicatorHighlightColorProperty, value); + } + + /// Thickness of the highlighted middle outline in pixels. Default 2. + public float IndicatorHighlightThickness + { + get => (float)GetValue(IndicatorHighlightThicknessProperty); + set => SetValue(IndicatorHighlightThicknessProperty, value); + } + + /// Inset of the highlighted outline from the indicator radius. Default 2. + public float IndicatorHighlightInset + { + get => (float)GetValue(IndicatorHighlightInsetProperty); + set => SetValue(IndicatorHighlightInsetProperty, value); + } + + /// Color of the indicator's inner outline. Default black. + public Color IndicatorInnerColor + { + get => (Color)GetValue(IndicatorInnerColorProperty); + set => SetValue(IndicatorInnerColorProperty, value); + } + + /// Thickness of the indicator's inner outline in pixels. Default 1. + public float IndicatorInnerThickness + { + get => (float)GetValue(IndicatorInnerThicknessProperty); + set => SetValue(IndicatorInnerThicknessProperty, value); + } + + /// Inset of the inner outline from the indicator radius. Default 4. + public float IndicatorInnerInset + { + get => (float)GetValue(IndicatorInnerInsetProperty); + set => SetValue(IndicatorInnerInsetProperty, value); + } + + /// Slider-track thickness divided by indicator radius. Default 1.3. + public float SliderTrackThicknessScale + { + get => (float)GetValue(SliderTrackThicknessScaleProperty); + set => SetValue(SliderTrackThicknessScaleProperty, value); + } + + /// Stroke cap used for slider tracks. Default round. + public SKStrokeCap SliderStrokeCap + { + get => (SKStrokeCap)GetValue(SliderStrokeCapProperty); + set => SetValue(SliderStrokeCapProperty, value); + } + + /// Stroke join used for slider tracks. Default round. + public SKStrokeJoin SliderStrokeJoin + { + get => (SKStrokeJoin)GetValue(SliderStrokeJoinProperty); + set => SetValue(SliderStrokeJoinProperty, value); + } + + /// Light color in the alpha-slider transparency pattern. + public Color AlphaPatternLightColor + { + get => (Color)GetValue(AlphaPatternLightColorProperty); + set => SetValue(AlphaPatternLightColorProperty, value); + } + + /// Dark color in the alpha-slider transparency pattern. Default transparent. + public Color AlphaPatternDarkColor + { + get => (Color)GetValue(AlphaPatternDarkColorProperty); + set => SetValue(AlphaPatternDarkColorProperty, value); + } + + /// Transparency-pattern cell size divided by indicator radius. + public float AlphaPatternCellSizeScale + { + get => (float)GetValue(AlphaPatternCellSizeScaleProperty); + set => SetValue(AlphaPatternCellSizeScaleProperty, value); + } + + /// Triangle hue-ring thickness divided by indicator radius. Default 2. + public float HueRingThicknessScale + { + get => (float)GetValue(HueRingThicknessScaleProperty); + set => SetValue(HueRingThicknessScaleProperty, value); + } + + /// Wheel luminosity-ring thickness divided by indicator radius. Default 1. + public float LuminosityRingThicknessScale + { + get => (float)GetValue(LuminosityRingThicknessScaleProperty); + set => SetValue(LuminosityRingThicknessScaleProperty, value); + } + + /// Color of the rotating triangle's radial hue indicator. + public Color TriangleHueIndicatorColor + { + get => (Color)GetValue(TriangleHueIndicatorColorProperty); + set => SetValue(TriangleHueIndicatorColorProperty, value); + } + + /// Thickness of the rotating triangle's hue indicator in pixels. + public float TriangleHueIndicatorThickness + { + get => (float)GetValue(TriangleHueIndicatorThicknessProperty); + set => SetValue(TriangleHueIndicatorThicknessProperty, value); + } + + /// Stroke cap of the rotating triangle's hue indicator. + public SKStrokeCap TriangleHueIndicatorStrokeCap + { + get => (SKStrokeCap)GetValue(TriangleHueIndicatorStrokeCapProperty); + set => SetValue(TriangleHueIndicatorStrokeCapProperty, value); + } + + static bool IsNonNegativeFloat(BindableObject _, object value) + => value is float number && float.IsFinite(number) && number >= 0; + + public override void Render(SKCanvas canvas, ColorPickerDrawingContext context) + { + switch (context) + { + case CanvasDrawingContext canvasContext: + DrawCanvas(canvas, canvasContext); + break; + case CircularBackgroundDrawingContext background: + DrawCircularBackground(canvas, background); + break; + case HueSaturationDiscDrawingContext disc: + DrawHueSaturationDisc(canvas, disc); + break; + case LuminosityRingDrawingContext luminosityRing: + DrawLuminosityRing(canvas, luminosityRing); + break; + case HueRingDrawingContext hueRing: + DrawHueRing(canvas, hueRing); + break; + case SaturationValueTriangleDrawingContext triangle: + DrawSaturationValueTriangle(canvas, triangle); + break; + case SliderTransparencyDrawingContext transparency: + DrawSliderTransparency(canvas, transparency); + break; + case SliderTrackDrawingContext slider: + DrawSliderTrack(canvas, slider); + break; + case IndicatorDrawingContext indicator: + DrawIndicator(canvas, indicator); + break; + case HueLineIndicatorDrawingContext lineIndicator: + DrawHueLineIndicator(canvas, lineIndicator); + break; + default: + DrawUnknown(canvas, context); + break; + } + } + + protected virtual void DrawUnknown(SKCanvas canvas, ColorPickerDrawingContext context) + { + } + + protected virtual void DrawCanvas(SKCanvas canvas, CanvasDrawingContext context) + => canvas.Clear(); + + protected virtual void DrawCircularBackground(SKCanvas canvas, CircularBackgroundDrawingContext context) + { + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Color = context.BackgroundColor.ToSKColor() + }; + canvas.DrawCircle(context.Center, context.Radius, paint); + } + + protected virtual void DrawHueSaturationDisc(SKCanvas canvas, HueSaturationDiscDrawingContext context) + { + using var hueShader = SKShader.CreateSweepGradient( + context.Center, + context.Gradient.ColorArray, + context.Gradient.PositionArray); + using (var huePaint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = hueShader, + Style = SKPaintStyle.Fill + }) + { + canvas.DrawCircle(context.Center, context.Radius, huePaint); + } + + var colors = new[] { SKColors.Gray, SKColors.Transparent }; + using var grayShader = SKShader.CreateRadialGradient( + context.Center, context.Radius, colors, null, SKShaderTileMode.Clamp); + using var grayPaint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = grayShader, + Style = SKPaintStyle.Fill + }; + canvas.DrawPaint(grayPaint); + } + + protected virtual void DrawLuminosityRing(SKCanvas canvas, LuminosityRingDrawingContext context) + { + var thickness = context.IndicatorRadius * LuminosityRingThicknessScale; + if (thickness <= 0) + return; + + var color = context.SelectedColor; + var colors = new[] + { + Color.FromHsla(color.GetHue(), color.GetSaturation(), 0.5).ToSKColor(), + Color.FromHsla(color.GetHue(), color.GetSaturation(), 1.0).ToSKColor(), + Color.FromHsla(color.GetHue(), color.GetSaturation(), 0.5).ToSKColor(), + Color.FromHsla(color.GetHue(), color.GetSaturation(), 0.0).ToSKColor(), + Color.FromHsla(color.GetHue(), color.GetSaturation(), 0.5).ToSKColor() + }; + + using var shader = SKShader.CreateSweepGradient(context.Center, colors, null); + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = shader, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness + }; + canvas.DrawCircle(context.Center, context.Radius, paint); + } + + protected virtual void DrawHueRing(SKCanvas canvas, HueRingDrawingContext context) + { + var thickness = context.IndicatorRadius * HueRingThicknessScale; + if (thickness <= 0) + return; + + using var shader = SKShader.CreateSweepGradient( + context.Center, + context.Gradient.ColorArray, + context.Gradient.PositionArray); + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = shader, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness + }; + canvas.DrawCircle(context.Center, context.Radius, paint); + } + + protected virtual void DrawSaturationValueTriangle( + SKCanvas canvas, + SaturationValueTriangleDrawingContext context) + { + canvas.Save(); + var transform = context.Transform; + canvas.Concat(in transform); + + var point1 = context.LocalHueVertex; + var point2 = context.LocalWhiteVertex; + var point3 = context.LocalBlackVertex; + + using (var triangleBuilder = new SKPathBuilder()) + { + triangleBuilder.MoveTo(point1); + triangleBuilder.LineTo(point2); + triangleBuilder.LineTo(point3); + using var triangle = triangleBuilder.Detach(); + canvas.ClipPath(triangle, SKClipOperation.Intersect, true); + } + + canvas.Save(); + canvas.RotateRadians(-(float)Math.PI / 3F, point3.X, point3.Y); + + var hueColor = Color.FromHsla(context.Hue, 1, 0.5).ToSKColor(); + var hueColors = new[] { hueColor, Colors.White.ToSKColor(), hueColor }; + var huePositions = new[] { 0F, 0.16666666666666F, 1F }; + using (var hueShader = SKShader.CreateSweepGradient(point3, hueColors, huePositions)) + using (var huePaint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = hueShader, + Style = SKPaintStyle.Fill + }) + { + canvas.DrawCircle(point3, context.Radius * 2, huePaint); + } + canvas.Restore(); + + var polarRadius = Distance(context.Center, point3) * TriangleHeight; + var angle = MathF.Atan2(context.Center.Y - point3.Y, context.Center.X - point3.X); + var gradientEnd = new SKPoint( + point3.X + (polarRadius * MathF.Cos(angle)), + point3.Y + (polarRadius * MathF.Sin(angle))); + + var valueColors = new[] { SKColors.Black, SKColors.Transparent }; + using var valueShader = SKShader.CreateLinearGradient( + point3, gradientEnd, valueColors, null, SKShaderTileMode.Clamp); + using var valuePaint = new SKPaint + { + IsAntialias = IsAntialias, + Shader = valueShader, + Style = SKPaintStyle.Fill + }; + canvas.DrawCircle(context.Center, context.Radius, valuePaint); + canvas.Restore(); + } + + protected virtual void DrawSliderTransparency( + SKCanvas canvas, + SliderTransparencyDrawingContext context) + { + var scale = context.IndicatorRadius * AlphaPatternCellSizeScale; + var trackThickness = context.IndicatorRadius * SliderTrackThicknessScale; + if (scale <= 0 || trackThickness <= 0) + return; + + using var pathBuilder = new SKPathBuilder(); + pathBuilder.MoveTo(-scale, -scale); + pathBuilder.LineTo(0, -scale); + pathBuilder.LineTo(0, 0); + pathBuilder.LineTo(scale, 0); + pathBuilder.LineTo(scale, scale); + pathBuilder.LineTo(0, scale); + pathBuilder.LineTo(0, 0); + pathBuilder.LineTo(-scale, 0); + pathBuilder.LineTo(-scale, -scale); + using var path = pathBuilder.Detach(); + + var halfThickness = trackThickness / 2F; + var capExtension = SliderStrokeCap == SKStrokeCap.Butt ? 0 : halfThickness; + var longitudinalExtension = capExtension + context.IndicatorPadding; + SKRect trackBounds; + if (context.Vertical) + { + trackBounds = new SKRect( + context.StartPoint.X - halfThickness, + Math.Min(context.StartPoint.Y, context.EndPoint.Y) - longitudinalExtension, + context.StartPoint.X + halfThickness, + Math.Max(context.StartPoint.Y, context.EndPoint.Y) + longitudinalExtension); + } + else + { + trackBounds = new SKRect( + Math.Min(context.StartPoint.X, context.EndPoint.X) - longitudinalExtension, + context.StartPoint.Y - halfThickness, + Math.Max(context.StartPoint.X, context.EndPoint.X) + longitudinalExtension, + context.StartPoint.Y + halfThickness); + } + + var cornerRadius = SliderStrokeCap == SKStrokeCap.Round ? halfThickness : 0; + var clipRoundRect = new SKRoundRect(trackBounds, cornerRadius, cornerRadius); + canvas.Save(); + canvas.ClipRoundRect(clipRoundRect); + + if (AlphaPatternDarkColor.Alpha > 0) + { + using var backgroundPaint = new SKPaint + { + IsAntialias = IsAntialias, + Color = AlphaPatternDarkColor.ToSKColor() + }; + canvas.DrawRect(trackBounds, backgroundPaint); + } + + var matrix = SKMatrix.CreateScale(2 * scale, 2 * scale); + using var pathEffect = SKPathEffect.Create2DPath(matrix, path); + using var patternPaint = new SKPaint + { + PathEffect = pathEffect, + Color = AlphaPatternLightColor.ToSKColor(), + IsAntialias = IsAntialias + }; + canvas.DrawRect(trackBounds, patternPaint); + canvas.Restore(); + } + + protected virtual void DrawSliderTrack(SKCanvas canvas, SliderTrackDrawingContext context) + { + var thickness = context.IndicatorRadius * SliderTrackThicknessScale; + if (thickness <= 0) + return; + + using var shader = SKShader.CreateLinearGradient( + context.StartPoint, + context.EndPoint, + context.Gradient.ColorArray, + context.Gradient.PositionArray, + SKShaderTileMode.Clamp); + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Style = SKPaintStyle.Stroke, + StrokeCap = SliderStrokeCap, + StrokeJoin = SliderStrokeJoin, + StrokeWidth = thickness, + Shader = shader + }; + canvas.DrawLine(context.StartPoint, context.EndPoint, paint); + } + + protected virtual void DrawIndicator(SKCanvas canvas, IndicatorDrawingContext context) + { + if (IndicatorFillColor.Alpha > 0) + { + using var fill = new SKPaint + { + IsAntialias = IsAntialias, + Style = SKPaintStyle.Fill, + Color = IndicatorFillColor.ToSKColor() + }; + canvas.DrawCircle(context.Center, context.Radius, fill); + } + + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Style = SKPaintStyle.Stroke, + Color = IndicatorHighlightColor.ToSKColor(), + StrokeWidth = IndicatorHighlightThickness + }; + + if (IndicatorHighlightThickness > 0) + { + canvas.DrawCircle( + context.Center, + Math.Max(0, context.Radius - IndicatorHighlightInset), + paint); + } + + paint.Color = IndicatorInnerColor.ToSKColor(); + paint.StrokeWidth = IndicatorInnerThickness; + if (IndicatorInnerThickness > 0) + { + canvas.DrawCircle( + context.Center, + Math.Max(0, context.Radius - IndicatorInnerInset), + paint); + } + + paint.Color = IndicatorOuterColor.ToSKColor(); + paint.StrokeWidth = IndicatorOuterThickness; + if (IndicatorOuterThickness > 0) + canvas.DrawCircle(context.Center, context.Radius, paint); + } + + protected virtual void DrawHueLineIndicator(SKCanvas canvas, HueLineIndicatorDrawingContext context) + { + if (TriangleHueIndicatorThickness <= 0) + return; + + using var paint = new SKPaint + { + IsAntialias = IsAntialias, + Style = SKPaintStyle.Stroke, + Color = TriangleHueIndicatorColor.ToSKColor(), + StrokeWidth = TriangleHueIndicatorThickness, + StrokeCap = TriangleHueIndicatorStrokeCap + }; + canvas.DrawLine(context.OuterPoint, context.InnerPoint, paint); + } + + static float Distance(SKPoint first, SKPoint second) + { + var x = first.X - second.X; + var y = first.Y - second.Y; + return MathF.Sqrt((x * x) + (y * y)); + } +} diff --git a/ColorPicker/Rendering/ColorGradient.cs b/ColorPicker/Rendering/ColorGradient.cs new file mode 100644 index 00000000..64608f5f --- /dev/null +++ b/ColorPicker/Rendering/ColorGradient.cs @@ -0,0 +1,32 @@ +namespace ColorPicker.Rendering; + +/// Immutable color and position data for a rendered gradient. +public sealed class ColorGradient +{ + readonly SKColor[] _colors; + readonly float[]? _positions; + + public ColorGradient(IEnumerable colors, IEnumerable? positions = null) + { + ArgumentNullException.ThrowIfNull(colors); + _colors = colors.ToArray(); + _positions = positions?.ToArray(); + + if (_colors.Length == 0) + throw new ArgumentException("A gradient requires at least one color.", nameof(colors)); + if (_positions is not null && _positions.Length != _colors.Length) + throw new ArgumentException("Gradient positions must match the number of colors.", nameof(positions)); + + Colors = Array.AsReadOnly(_colors); + Positions = _positions is null ? null : Array.AsReadOnly(_positions); + } + + /// Gradient colors in start-to-end order. + public IReadOnlyList Colors { get; } + + /// Optional normalized positions corresponding to . + public IReadOnlyList? Positions { get; } + + internal SKColor[] ColorArray => _colors; + internal float[]? PositionArray => _positions; +} diff --git a/ColorPicker/Rendering/ColorPickerDrawingContext.cs b/ColorPicker/Rendering/ColorPickerDrawingContext.cs new file mode 100644 index 00000000..7f6ec051 --- /dev/null +++ b/ColorPicker/Rendering/ColorPickerDrawingContext.cs @@ -0,0 +1,150 @@ +namespace ColorPicker.Rendering; + +/// Identifies the semantic purpose of an indicator. +public enum IndicatorRole +{ + HueSaturation, + Luminosity, + SaturationValue, + Hue, + Slider +} + +/// Identifies the color channel represented by a slider. +public enum SliderChannel +{ + Custom, + Hue, + Saturation, + Luminosity, + Red, + Green, + Blue, + Alpha +} + +/// Identifies the circular area whose background is being rendered. +public enum CircularBackgroundRole +{ + ColorDisc, + ColorTriangle +} + +/// +/// Base context supplied to a renderer. Coordinates are physical Skia canvas pixels. +/// +public abstract record ColorPickerDrawingContext( + SKSize CanvasSize, + Color SelectedColor); + +/// Starts a new picker frame. The classic renderer clears the canvas. +public sealed record CanvasDrawingContext( + SKSize CanvasSize, + Color SelectedColor) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the background behind a circular picker element. +public sealed record CircularBackgroundDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + Color BackgroundColor, + CircularBackgroundRole Role) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the hue/saturation surface of a color disc. +public sealed record HueSaturationDiscDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + ColorGradient Gradient) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the luminosity ring surrounding a color disc. +public sealed record LuminosityRingDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + float IndicatorRadius) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the hue ring surrounding a saturation/value triangle. +public sealed record HueRingDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + float IndicatorRadius, + ColorGradient Gradient) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// +/// Describes the saturation/value triangle, including its final vertices and rotation. +/// +public sealed record SaturationValueTriangleDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + double Hue, + float RotationRadians, + bool RotatesWithHue, + SKMatrix Transform, + SKPoint LocalHueVertex, + SKPoint LocalWhiteVertex, + SKPoint LocalBlackVertex, + SKPoint HueVertex, + SKPoint WhiteVertex, + SKPoint BlackVertex) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the transparency pattern drawn beneath an alpha slider. +public sealed record SliderTransparencyDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + bool Vertical, + SKPoint StartPoint, + SKPoint EndPoint, + float IndicatorRadius, + float IndicatorPadding, + SliderChannel Channel) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes a slider track and its semantic gradient. +public sealed record SliderTrackDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint StartPoint, + SKPoint EndPoint, + float IndicatorRadius, + float Value, + bool Vertical, + SliderChannel Channel, + ColorGradient Gradient) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes a point indicator. +public sealed record IndicatorDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint Center, + float Radius, + IndicatorRole Role, + SliderChannel Channel = SliderChannel.Custom, + SKPoint NormalizedPosition = default, + float? AngleRadians = null, + bool IsActive = false) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); + +/// Describes the radial line indicator used by the rotating triangle hue ring. +public sealed record HueLineIndicatorDrawingContext( + SKSize CanvasSize, + Color SelectedColor, + SKPoint OuterPoint, + SKPoint InnerPoint, + float AngleRadians, + bool IsActive) + : ColorPickerDrawingContext(CanvasSize, SelectedColor); diff --git a/ColorPicker/Rendering/ColorPickerRenderer.cs b/ColorPicker/Rendering/ColorPickerRenderer.cs new file mode 100644 index 00000000..91d042e9 --- /dev/null +++ b/ColorPicker/Rendering/ColorPickerRenderer.cs @@ -0,0 +1,24 @@ +namespace ColorPicker.Rendering; + +/// +/// Bindable base class for renderers. Property changes automatically invalidate +/// every control currently using the renderer. +/// +public abstract class ColorPickerRenderer : BindableObject, IColorPickerRenderer +{ + readonly WeakEventManager _eventManager = new(); + + public event EventHandler? Invalidated + { + add => _eventManager.AddEventHandler(value); + remove => _eventManager.RemoveEventHandler(value); + } + + public abstract void Render(SKCanvas canvas, ColorPickerDrawingContext context); + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + _eventManager.HandleEvent(this, EventArgs.Empty, nameof(Invalidated)); + } +} diff --git a/ColorPicker/Rendering/ColorPickerRenderers.cs b/ColorPicker/Rendering/ColorPickerRenderers.cs new file mode 100644 index 00000000..0279260d --- /dev/null +++ b/ColorPicker/Rendering/ColorPickerRenderers.cs @@ -0,0 +1,8 @@ +namespace ColorPicker.Rendering; + +/// Built-in color-picker renderers. +public static class ColorPickerRenderers +{ + /// Creates a renderer reproducing the library's original visual appearance. + public static ClassicColorPickerRenderer CreateClassic() => new(); +} diff --git a/ColorPicker/Rendering/IColorPickerRenderer.cs b/ColorPicker/Rendering/IColorPickerRenderer.cs new file mode 100644 index 00000000..28b94e03 --- /dev/null +++ b/ColorPicker/Rendering/IColorPickerRenderer.cs @@ -0,0 +1,17 @@ +namespace ColorPicker.Rendering; + +/// +/// Draws semantic color-picker elements. Implement this interface for a complete +/// renderer, or inherit a bundled renderer to replace selected elements. +/// +public interface IColorPickerRenderer +{ + /// Raised when renderer properties change and subscribed controls should repaint. + event EventHandler? Invalidated; + + /// + /// Draw one semantic element using the supplied immutable context. Implementations + /// should ignore unknown context subclasses for forward compatibility. + /// + void Render(SKCanvas canvas, ColorPickerDrawingContext context); +} diff --git a/ColorPickerTestApp/LayoutTestPage.xaml.cs b/ColorPickerTestApp/LayoutTestPage.xaml.cs index 8059707e..ec50ec8d 100644 --- a/ColorPickerTestApp/LayoutTestPage.xaml.cs +++ b/ColorPickerTestApp/LayoutTestPage.xaml.cs @@ -1,13 +1,19 @@ +using System.ComponentModel; using ColorPicker.BaseClasses; using ColorPicker.Controls; +using ColorPicker.Rendering; +using ColorPickerTestApp.Renderers; namespace ColorPickerTestApp; public partial class LayoutTestPage : ContentPage { + readonly RendererSettings _rendererSettings = new(); + public LayoutTestPage() { InitializeComponent(); + BindingContext = _rendererSettings; // Apply default scenario on initial render so a test that doesn't // explicitly set one still has something to query. Loaded += (_, _) => ApplyScenario(ScenarioEntry.Text); @@ -102,7 +108,7 @@ static string MakeFeatureKey(string control, string[] opts) foreach (var o in opts) { var t = o.Trim().ToLowerInvariant(); - if (t is "alpha" or "lumslider" or "nolumwheel" or "vertical") + if (t is "alpha" or "lumslider" or "nolumwheel" or "vertical" or "customindicator") flags.Add(t); } flags.Sort(StringComparer.Ordinal); @@ -114,7 +120,7 @@ static string MakeFeatureKey(string control, string[] opts) foreach (var o in opts) { var t = o.Trim().ToLowerInvariant(); - if (t is "norotate" or "rotate") flags.Add(t); + if (t is "norotate" or "rotate" or "customindicator") flags.Add(t); } flags.Sort(StringComparer.Ordinal); return string.Join(",", flags); @@ -125,7 +131,7 @@ static string MakeFeatureKey(string control, string[] opts) foreach (var o in opts) { var t = o.Trim().ToLowerInvariant(); - if (t is "vertical" or "noalpha") flags.Add(t); + if (t is "vertical" or "noalpha" or "customindicator") flags.Add(t); // IndicatorRadiusScale change affects measure; rebuild to be safe. else if (t.StartsWith("prs=")) flags.Add(t); } @@ -261,6 +267,8 @@ void ApplyScenario(string spec) var (control, wMode, wValue, hMode, hValue, opts) = Parse(spec); T($"PARSED:{control} {wMode}={wValue} x {hMode}={hValue} opts=[{string.Join(",", opts)}]"); + _rendererSettings.IndicatorFillColor = + ParseColorOpt(opts, "bindfill") ?? Colors.Transparent; // Update _lastSpec FIRST so SizeChanged events triggered by the // WidthRequest/HeightRequest assignments below report the new spec. @@ -314,6 +322,7 @@ void ApplyScenario(string spec) }; T($"BUILT-CHILD type={child.GetType().Name}"); ScenarioContent.Content = child; + ApplyPostAttachOptions(child, opts); T($"CONTENT-SET; hb.W={HostBorder.Width:0} hb.H={HostBorder.Height:0} sc.W={ScenarioContent.Width:0} sc.H={ScenarioContent.Height:0}"); _lastControl = control; _lastSizeKey = sizeKey; _lastFeatureKey = featureKey; @@ -339,6 +348,7 @@ static bool TryReconfigure(View existing, string control, string[] opts) w.ShowLuminosityRing = true; w.Vertical = false; w.CanvasBackgroundColor = Colors.Transparent; + ApplyRenderer(w, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; // handled separately @@ -348,6 +358,14 @@ static bool TryReconfigure(View existing, string control, string[] opts) case "lumslider": w.ShowLuminositySlider = true; break; case "nolumwheel": w.ShowLuminosityRing = false; break; case "vertical": w.Vertical = true; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; + case "removealphaafterattach": + w.ShowAlphaSlider = true; + w.ShowAlphaSlider = false; + break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -360,6 +378,7 @@ static bool TryReconfigure(View existing, string control, string[] opts) { t.CanvasBackgroundColor = ParseColorOpt(opts, "wbg") ?? Colors.Transparent; t.RotateTriangleByHue = true; + ApplyRenderer(t, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; @@ -367,6 +386,10 @@ static bool TryReconfigure(View existing, string control, string[] opts) { case "norotate": t.RotateTriangleByHue = false; break; case "rotate": t.RotateTriangleByHue = true; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -378,6 +401,7 @@ static bool TryReconfigure(View existing, string control, string[] opts) s.Vertical = false; s.ShowAlphaSlider = true; s.IndicatorRadiusScale = 0F; + ApplyRenderer(s, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; @@ -385,6 +409,10 @@ static bool TryReconfigure(View existing, string control, string[] opts) { case "vertical": s.Vertical = true; break; case "noalpha": s.ShowAlphaSlider = false; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -401,6 +429,7 @@ static bool TryReconfigure(View existing, string control, string[] opts) static T MakeSliders(string[] opts) where T : SliderStackWithAlpha, new() { var s = new T { AutomationId = "ScenarioControl" }; + ApplyRenderer(s, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; @@ -408,6 +437,10 @@ static bool TryReconfigure(View existing, string control, string[] opts) { case "vertical": s.Vertical = true; break; case "noalpha": s.ShowAlphaSlider = false; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -420,6 +453,7 @@ static bool TryReconfigure(View existing, string control, string[] opts) static ColorWheel MakeWheel(string[] opts) { var wheel = new ColorWheel { AutomationId = "ScenarioControl" }; + ApplyRenderer(wheel, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; @@ -430,6 +464,11 @@ static ColorWheel MakeWheel(string[] opts) case "noLumWheel": case "nolumwheel": wheel.ShowLuminosityRing = false; break; case "vertical": wheel.Vertical = true; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; + case "removealphaafterattach": wheel.ShowAlphaSlider = true; break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -441,6 +480,7 @@ static ColorWheel MakeWheel(string[] opts) static ColorTriangle MakeTriangle(string[] opts) { var t = new ColorTriangle { AutomationId = "ScenarioControl" }; + ApplyRenderer(t, opts); foreach (var opt in opts) { if (IsKvOpt(opt, out _, out _)) continue; @@ -448,6 +488,10 @@ static ColorTriangle MakeTriangle(string[] opts) { case "norotate": t.RotateTriangleByHue = false; break; case "rotate": t.RotateTriangleByHue = true; break; + case "customindicator": break; + case "overrestore": break; + case "zerostrokes": break; + case "zerostrokestransparent": break; case "": break; default: throw new ArgumentException("Unknown option: " + opt); } @@ -457,6 +501,99 @@ static ColorTriangle MakeTriangle(string[] opts) return t; } + static void ApplyRenderer(ColorPickerBase picker, string[] opts) + { + if (opts.Any(opt => opt.Trim().Equals( + "overrestore", + StringComparison.OrdinalIgnoreCase))) + { + if (picker.Renderer is not TestOverRestoreRenderer) + picker.Renderer = new TestOverRestoreRenderer(); + return; + } + + if (opts.Any(opt => opt.Trim().Equals( + "customindicator", + StringComparison.OrdinalIgnoreCase))) + { + if (picker.Renderer is not TestIndicatorRenderer) + picker.Renderer = new TestIndicatorRenderer(); + return; + } + + var renderer = picker.Renderer.GetType() == typeof(ClassicColorPickerRenderer) + ? (ClassicColorPickerRenderer)picker.Renderer + : new ClassicColorPickerRenderer(); + if (ParseColorOpt(opts, "bindfill") is not null) + { + renderer.SetBinding( + ClassicColorPickerRenderer.IndicatorFillColorProperty, + nameof(RendererSettings.IndicatorFillColor)); + } + else + { + renderer.RemoveBinding(ClassicColorPickerRenderer.IndicatorFillColorProperty); + renderer.IndicatorFillColor = ParseColorOpt(opts, "ifill") ?? Colors.Transparent; + } + + renderer.IndicatorOuterColor = Colors.Black; + renderer.IndicatorHighlightColor = Colors.White; + renderer.IndicatorInnerColor = Colors.Black; + renderer.IndicatorOuterThickness = 1; + renderer.IndicatorHighlightThickness = 2; + renderer.IndicatorInnerThickness = 1; + + var zeroStrokes = opts.Any(opt => opt.Trim().Equals( + "zerostrokes", + StringComparison.OrdinalIgnoreCase)); + var transparentZeroStrokes = opts.Any(opt => opt.Trim().Equals( + "zerostrokestransparent", + StringComparison.OrdinalIgnoreCase)); + if (zeroStrokes || transparentZeroStrokes) + { + renderer.IndicatorOuterColor = zeroStrokes ? Color.FromArgb("#010203") : Colors.Transparent; + renderer.IndicatorHighlightColor = zeroStrokes ? Color.FromArgb("#040506") : Colors.Transparent; + renderer.IndicatorInnerColor = zeroStrokes ? Color.FromArgb("#070809") : Colors.Transparent; + renderer.IndicatorOuterThickness = 0; + renderer.IndicatorHighlightThickness = 0; + renderer.IndicatorInnerThickness = 0; + } + picker.Renderer = renderer; + } + + static void ApplyPostAttachOptions(View child, string[] opts) + { + if (child is ColorWheel wheel && + opts.Any(opt => opt.Trim().Equals( + "removealphaafterattach", + StringComparison.OrdinalIgnoreCase))) + { + wheel.ShowAlphaSlider = false; + } + } + + sealed class RendererSettings : INotifyPropertyChanged + { + Color _indicatorFillColor = Colors.Transparent; + + public Color IndicatorFillColor + { + get => _indicatorFillColor; + set + { + if (_indicatorFillColor == value) + return; + + _indicatorFillColor = value; + PropertyChanged?.Invoke( + this, + new PropertyChangedEventArgs(nameof(IndicatorFillColor))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } + static bool IsKvOpt(string opt, out string key, out string value) { var idx = opt.IndexOf('='); diff --git a/ColorPickerTestApp/MainPage.xaml b/ColorPickerTestApp/MainPage.xaml index 087f20f1..ae791126 100644 --- a/ColorPickerTestApp/MainPage.xaml +++ b/ColorPickerTestApp/MainPage.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:controls="clr-namespace:ColorPicker.Controls;assembly=ColorPicker" + xmlns:rendering="clr-namespace:ColorPicker.Rendering;assembly=ColorPicker" xmlns:cvt="clr-namespace:ColorPickerTestApp.Converters" xmlns:skia="clr-namespace:SkiaSharp.Views.Maui.Controls;assembly=SkiaSharp.Views.Maui.Controls"> @@ -162,7 +163,13 @@ ShowAlphaSlider="{Binding Source={x:Reference ShowAlphaSwitch}, Path=IsToggled}" ShowLuminositySlider="{Binding Source={x:Reference ShowLuminositySlider}, Path=IsToggled}" ShowLuminosityRing="{Binding Source={x:Reference ShowLuminosityRing}, Path=IsToggled}" - Vertical="{Binding Source={x:Reference ShowVerticalSlider}, Path=IsToggled}" /> + Vertical="{Binding Source={x:Reference ShowVerticalSlider}, Path=IsToggled}"> + + + + +/// UI-test renderer proving that one visual element can be replaced while every +/// other element continues to use the classic renderer. +/// +public sealed class TestIndicatorRenderer : ClassicColorPickerRenderer +{ + public static readonly SKColor TestColor = new(1, 2, 3); + + protected override void DrawIndicator(SKCanvas canvas, IndicatorDrawingContext context) + { + using var paint = new SKPaint + { + IsAntialias = false, + Color = TestColor, + Style = SKPaintStyle.Fill + }; + + var halfSize = context.Radius * 0.75F; + canvas.DrawRect( + context.Center.X - halfSize, + context.Center.Y - halfSize, + halfSize * 2, + halfSize * 2, + paint); + } +} diff --git a/ColorPickerTestApp/Renderers/TestOverRestoreRenderer.cs b/ColorPickerTestApp/Renderers/TestOverRestoreRenderer.cs new file mode 100644 index 00000000..3876b3bc --- /dev/null +++ b/ColorPickerTestApp/Renderers/TestOverRestoreRenderer.cs @@ -0,0 +1,17 @@ +using ColorPicker.Rendering; + +namespace ColorPickerTestApp.Renderers; + +/// +/// UI-test renderer that deliberately over-restores the canvas before changing +/// its transform. Later elements must remain unaffected by this callback. +/// +public sealed class TestOverRestoreRenderer : ClassicColorPickerRenderer +{ + protected override void DrawCanvas(SKCanvas canvas, CanvasDrawingContext context) + { + canvas.Restore(); + canvas.Translate(50, 0); + base.DrawCanvas(canvas, context); + } +} diff --git a/README.md b/README.md index 79e84e5f..667a2018 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ data-bindable — and any number of pickers can be linked so they stay in sync. `DelegateSlider` for building custom single-channel sliders. - **Two-way `SelectedColor` binding** and a `SelectedColorChanged` event. - **Linked pickers** — set `AttachedColorPicker` and multiple controls edit the same color. -- **Customizable** — horizontal/vertical orientation, indicator (picker-dot) size, and a - canvas background color. +- **Fully customizable rendering** — configure the bundled classic renderer or replace + any visual element with your own SkiaSharp drawing code. ## Install @@ -96,6 +96,7 @@ All pickers derive from `ColorPickerBase` and share these members: |---|---|---|---| | `SelectedColor` | `Color` | `HSL(0, 0, 0.5)` (mid-gray) | The current color. Bindable, two-way. | | `AttachedColorPicker` | `IColorPicker` | `null` | Another picker to keep in sync with this one. | +| `Renderer` | `IColorPickerRenderer` | `ClassicColorPickerRenderer` | Draws every visual element; replace it globally or per control. | | `SelectedColorChanged` | `event` | — | Raised on change; args expose `OldColor` / `NewColor`. | ### `ColorWheel` @@ -119,6 +120,87 @@ All pickers derive from `ColorPickerBase` and share these members: | `CanvasBackgroundColor` | `Color` | `Transparent` | Fill drawn behind the triangle. | | `IndicatorRadiusScale` | `float` | `0.035` | Picker-dot radius as a fraction of the canvas. | +## Custom rendering + +All picker geometry, color math, layout, and hit testing remain owned by the controls. +Rendering is delegated to an `IColorPickerRenderer`, which receives an `SKCanvas` and an +immutable semantic context containing the relevant pixel coordinates, radii, colors, +angles, normalized values, channel role, and interaction state. + +The default `ClassicColorPickerRenderer` reproduces the standard appearance and exposes +bindable high-level properties: + +```xml + + + + + + + + +``` + +Common classic-renderer options include: + +| Property group | Options | +|---|---| +| Indicator | Fill, outer/highlight/inner colors, outline thicknesses, and outline insets | +| Slider | Track-thickness scale, stroke cap, and stroke join | +| Alpha pattern | Light/dark colors and cell-size scale | +| Rings | Hue-ring and luminosity-ring thickness scales | +| Triangle hue marker | Color, thickness, and stroke cap | +| General | Antialiasing | + +Renderer instances can also be placed in a resource dictionary and shared by controls. +Use literal property values when sharing a renderer. A renderer with bindings should only +be shared by controls that use the same binding context; renderer objects are not visual +elements, so MAUI dynamic-resource lookup is not available on them. + +To replace only one element, inherit the classic renderer and override the corresponding +protected method. All other elements continue to use the classic implementation: + +```csharp +using ColorPicker.Rendering; +using SkiaSharp; + +public sealed class SquareIndicatorRenderer : ClassicColorPickerRenderer +{ + protected override void DrawIndicator( + SKCanvas canvas, + IndicatorDrawingContext context) + { + using var paint = new SKPaint + { + Color = SKColors.White, + Style = SKPaintStyle.Fill, + IsAntialias = true, + }; + + var radius = context.Radius; + canvas.DrawRect( + context.Center.X - radius, + context.Center.Y - radius, + radius * 2, + radius * 2, + paint); + } +} +``` + +For a completely independent visual language, inherit `ColorPickerRenderer` or implement +`IColorPickerRenderer` and handle each context directly. Bundled renderer source code is +also intended to serve as a complete implementation reference. + ## Linking pickers Set `AttachedColorPicker` to mirror the color between two (or more) controls — edit either