Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 33 additions & 1 deletion ColorPicker.UITests/PageObjects/LayoutTestPageObject.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions ColorPicker.UITests/Tests/RendererCustomizationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using ColorPicker.UITests.Infrastructure;

namespace ColorPicker.UITests.Tests;

[Collection(AppiumServerCollection.Name)]
public sealed class RendererCustomizationTests
: IClassFixture<AppiumServerFixture>, IClassFixture<LayoutTestAppFixture>
{
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;
}
}
97 changes: 95 additions & 2 deletions ColorPicker/BaseClasses/ColorPickerBase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace ColorPicker.BaseClasses;

using ColorPicker.Core.Connection;
using ColorPicker.Rendering;

namespace ColorPicker.BaseClasses;

/// <summary>
/// ColorPicker base class
Expand All @@ -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
//
/// <summary>The currently selected color. Bindable (two-way); changes raise
Expand All @@ -46,6 +56,16 @@ public IColorPicker AttachedColorPicker
set => SetValue(AttachedColorPickerProperty, value);
}

/// <summary>
/// Renderer responsible for every visual element. Defaults to
/// <see cref="ClassicColorPickerRenderer"/>.
/// </summary>
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.
//
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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();

/// <summary>
/// Raised after <see cref="SelectedColor"/> changes, with the old and new color.
/// </summary>
Expand Down
29 changes: 12 additions & 17 deletions ColorPicker/BaseClasses/SkiaPickerBase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
namespace ColorPicker.BaseClasses;

using ColorPicker.Behaviors;
using ColorPicker.Rendering;
#if WINDOWS
using ColorPicker.Platforms.WinUI;
#elif ANDROID
Expand All @@ -9,6 +8,8 @@

using SkiaSharp.Views.Maui.Controls;

namespace ColorPicker.BaseClasses;

/// <summary>
/// Base class for the SkiaSharp-drawn pickers (disc, triangle, sliders). Owns the
/// canvas, touch handling, and the indicator dot.
Expand Down Expand Up @@ -94,7 +95,7 @@
public abstract float GetIndicatorRadiusPixels();
public abstract float GetIndicatorRadiusPixels(SKSize canvasSize);

protected abstract SizeRequest GetMeasure(double widthConstraint, double heightConstraint);

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Build Android

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Build Android

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Build Windows

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Build Windows

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / UI Tests (Windows)

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Pack NuGet

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'

Check warning on line 98 in ColorPicker/BaseClasses/SkiaPickerBase.cs

View workflow job for this annotation

GitHub Actions / build-and-test / Pack NuGet

'SizeRequest' is obsolete: 'Use Microsoft.Maui.Size instead.'
protected abstract float GetSize();
protected abstract float GetSize(SKSize canvasSize);
protected abstract void OnPaintSurface(SKCanvas canvas, int width, int height);
Expand All @@ -103,6 +104,8 @@
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
Expand Down Expand Up @@ -156,22 +159,14 @@
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)
Expand Down
12 changes: 10 additions & 2 deletions ColorPicker/BaseClasses/SliderBase.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading