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
39 changes: 23 additions & 16 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

permissions:
contents: read

jobs:
build:
name: Build (${{ matrix.os }})
Expand All @@ -42,10 +45,10 @@ jobs:
- name: Determine version suffix
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> "$GITHUB_ENV"
else
echo "VERSION_SUFFIX=-build.${{ github.run_number }}" >> $GITHUB_ENV
echo "VERSION_SUFFIX=-build.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
fi

- name: Restore
Expand All @@ -58,6 +61,9 @@ jobs:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: build
permissions:
contents: read
checks: write
strategy:
fail-fast: false
matrix:
Expand All @@ -76,10 +82,10 @@ jobs:
- name: Determine version suffix
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> "$GITHUB_ENV"
else
echo "VERSION_SUFFIX=-build.${{ github.run_number }}" >> $GITHUB_ENV
echo "VERSION_SUFFIX=-build.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
fi

- name: Restore
Expand All @@ -102,7 +108,8 @@ jobs:

- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
# Fork and Dependabot PR tokens cannot create check runs; TRX artifacts still upload above.
if: ${{ always() && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) }}
with:
name: Test Results (${{ matrix.os }})
path: artifacts/test/**/*.trx
Expand Down Expand Up @@ -131,10 +138,10 @@ jobs:
- name: Determine version suffix
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> "$GITHUB_ENV"
else
echo "VERSION_SUFFIX=-build.${{ github.run_number }}" >> $GITHUB_ENV
echo "VERSION_SUFFIX=-build.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
fi

- name: Restore library
Expand Down Expand Up @@ -167,10 +174,10 @@ jobs:
- name: Determine version suffix
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> "$GITHUB_ENV"
else
echo "VERSION_SUFFIX=-build.${{ github.run_number }}" >> $GITHUB_ENV
echo "VERSION_SUFFIX=-build.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
fi

- name: Restore
Expand Down Expand Up @@ -211,10 +218,10 @@ jobs:
- name: Determine version suffix
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> $GITHUB_ENV
if [[ "$GITHUB_REF" == refs/heads/release/* ]]; then
echo "VERSION_SUFFIX=" >> "$GITHUB_ENV"
else
echo "VERSION_SUFFIX=-build.${{ github.run_number }}" >> $GITHUB_ENV
echo "VERSION_SUFFIX=-build.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
fi

- name: Restore
Expand Down
13 changes: 11 additions & 2 deletions src/Svg.Controls.Skia.Avalonia/Svg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,6 @@ private void DispatchPointerPressed(PointerPressedEventArgs e)
return;
}

e.Pointer.Capture(this);
var currentPoint = e.GetCurrentPoint(this);
var button = MapPointerUpdateKind(currentPoint.Properties.PointerUpdateKind);
var result = Interaction.DispatchPointerPressed(skSvg, CreatePointerInput(e, button, e.ClickCount, 0));
Comment on lines 891 to 893
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore pointer capture on press to avoid stuck SVG capture

Without capturing the Avalonia pointer on press, a PointerReleased that occurs outside this control will not be delivered here, so Interaction.DispatchPointerReleased(...) never runs to clear the dispatcher’s internal _capturedElement/_pressedElement state. In that path, later DispatchPointerMoved calls continue routing to the stale captured SVG element (_capturedElement ?? hitTarget), which breaks hover/cursor updates and event targeting after an outside release until another full press/release cycle happens.

Useful? React with 👍 / 👎.

Expand All @@ -908,7 +907,17 @@ private void DispatchPointerReleased(PointerReleasedEventArgs e)
var result = Interaction.DispatchPointerReleased(skSvg, CreatePointerInput(e, button, 0, 0));
e.Handled |= result.Handled;
ApplyNativeCursor(result.Cursor);
e.Pointer.Capture(null);
}

protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
{
base.OnPointerCaptureLost(e);

if (Interaction.CapturedElement is not null || Interaction.PressedElement is not null)
{
Interaction.Reset();
ApplyNativeCursor(null);
}
}

private void DispatchPointerWheelChanged(PointerWheelEventArgs e)
Expand Down
154 changes: 154 additions & 0 deletions tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgControlTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
using System;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Svg.Skia;
using Svg.Skia;
using Xunit;
Expand All @@ -9,6 +14,18 @@ namespace Avalonia.Svg.Skia.UnitTests;

public class SvgControlTests
{
private const string ButtonSvg = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="#3B82F6"/>
</svg>
""";

private const string InteractiveSvg = """
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80">
<rect id="hit" x="0" y="0" width="80" height="80" fill="#3B82F6"/>
</svg>
""";

[AvaloniaFact]
public void AnimationPlaybackRate_NormalizesNonFiniteValues()
{
Expand Down Expand Up @@ -69,6 +86,143 @@ public void CurrentAnimationFrameCallback_AdvancesCurrentLoopState()
Assert.False((bool)GetPrivateField(svg, "_animationRenderLoopRequested"));
}

[AvaloniaFact]
public void SvgContent_BubblesPointerEventsToParentButton()
{
var pressed = 0;
var released = 0;
var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/"))
{
Source = ButtonSvg,
Width = 80,
Height = 80
};
var button = new Button
{
Content = svg
};
button.AddHandler(
InputElement.PointerPressedEvent,
(_, _) => pressed++,
RoutingStrategies.Bubble,
handledEventsToo: true);
button.AddHandler(
InputElement.PointerReleasedEvent,
(_, _) => released++,
RoutingStrategies.Bubble,
handledEventsToo: true);
var window = new Window
{
Width = 160,
Height = 160,
Content = button
};

window.Show();
try
{
window.MouseMove(new Point(80, 80), RawInputModifiers.None);
window.MouseDown(new Point(80, 80), MouseButton.Left, RawInputModifiers.None);
Assert.True(button.IsPressed);
window.MouseUp(new Point(80, 80), MouseButton.Left, RawInputModifiers.None);

Assert.Equal(1, pressed);
Assert.Equal(1, released);
}
finally
{
window.Close();
}
}

[AvaloniaFact]
public void SvgInteractionCapture_ClearsWhenReleasedOutsideControl()
{
var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/"))
{
Source = InteractiveSvg,
Width = 80,
Height = 80
};
var canvas = new Canvas
{
Width = 220,
Height = 180,
Children = { svg }
};
Canvas.SetLeft(svg, 20);
Canvas.SetTop(svg, 20);
var window = new Window
{
Width = 220,
Height = 180,
Content = canvas
};

window.Show();
try
{
window.MouseMove(new Point(40, 40), RawInputModifiers.None);
window.MouseDown(new Point(40, 40), MouseButton.Left, RawInputModifiers.None);
Assert.NotNull(svg.Interaction.CapturedElement);

window.MouseUp(new Point(180, 140), MouseButton.Left, RawInputModifiers.None);

Assert.Null(svg.Interaction.CapturedElement);
}
finally
{
window.Close();
}
}

[AvaloniaFact]
public void SvgInteractionCapture_ClearsWhenPointerCaptureLost()
{
var svg = new CaptureLossSvg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/"))
{
Source = InteractiveSvg,
Width = 80,
Height = 80
};
var window = new Window
{
Width = 160,
Height = 160,
Content = svg
};

window.Show();
try
{
window.MouseMove(new Point(40, 40), RawInputModifiers.None);
window.MouseDown(new Point(40, 40), MouseButton.Left, RawInputModifiers.None);
Assert.NotNull(svg.Interaction.CapturedElement);
Assert.NotNull(svg.Interaction.PressedElement);
Assert.NotNull(svg.PressedPointer);

svg.PressedPointer!.Capture(null);

Assert.Null(svg.Interaction.CapturedElement);
Assert.Null(svg.Interaction.PressedElement);
}
finally
{
window.Close();
}
}

private sealed class CaptureLossSvg(Uri baseUri) : Svg(baseUri)
{
public IPointer? PressedPointer { get; private set; }

protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
PressedPointer = e.Pointer;
}
}

private static void InvokeAnimationFrameCallback(Svg svg, long generation)
{
var callback = typeof(Svg).GetMethod(
Expand Down
Loading