[net11.0] Add public IImageSourcePaint contract for external platform backends - #37864
[net11.0] Add public IImageSourcePaint contract for external platform backends#37864Redth wants to merge 2 commits into
Conversation
MAUI represents an image background as the internal `ImageSourcePaint` type. Out-of-tree platform backends receive backgrounds as a `Paint` via `IView.Background`, so they cannot detect an image background or read its `IImageSource` without reflection or `InternalsVisibleTo`. Adds a minimal, additive public contract, `Microsoft.Maui.IImageSourcePaint`, exposing a single read-only `ImageSource` property. The internal `ImageSourcePaint` now implements it, and every in-tree consumer pattern matches on the interface instead of the concrete type. This is purely additive: - `ImageSourcePaint` stays internal, so no implementation detail is exposed. - No brush/paint API, XAML, or serialization surface changes; `ImageBrush` round-trips exactly as before. - Interface pattern matching is trim/AOT safe and adds no reflection. - Equality and built-in platform behavior are unchanged. Because in-tree handlers now match the interface, a backend (or app) can also supply its own `Paint` implementing `IImageSourcePaint` and have the built-in handlers render it as an image background. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ec0bf62-da2c-49fb-af27-a0a79cbbd6bb
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37864Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37864" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
Architecture review found the backend was not actually substitutable for MAUI's
own handlers: it owned a parallel ITizen*Handler hierarchy and chained only the
Tizen base mapper, so MAUI Controls' RemapForControls dispatch never reached it.
Every claim the redesign rests on was verified against the pinned package
(11.0.0-preview.7.26426.4) before any code changed, because the previous design
was justified by an objection that turned out to be obsolete:
* Microsoft.Maui.Core ships NO Tizen asset - only net11.0, -android, -ios,
-maccatalyst and -windows. A net11.0-tizen11.0 project therefore resolves the
neutral assembly, where every handler interface types PlatformView as object.
The CS9333 alias mismatch that motivated the backend-only interfaces cannot
occur. Confirmed by implementing IButtonHandler explicitly: it compiled.
* ImageSourcePartLoader is public, which is what unblocked IButtonHandler.
* Chaining MAUI's static mapper is LIVE, not a snapshot: a mapper built before
Controls' static constructor runs still picks up the remap. Measured.
* All 39 chained Button mappings dispatch against a backend handler with zero
InvalidCastException. The hard-cast concern is real but satisfied.
So: all fourteen handlers now implement MAUI's real IXHandler, the backend-only
interfaces are gone, and each mapper is composed in three layers - MAUI's static
mapper (carrying Controls' remaps), then the Tizen view mappings over its no-op
bodies, then the handler's own keys, which must win because Entry.Background has
to re-evaluate the container before painting.
Parity is now measured against Controls rather than Core. The test project
references Microsoft.Maui.Controls.Core and forces the static remaps before
reading any mapper; measuring beforehand reported a parity the backend did not
have. The generated matrix gained a third state as a direct consequence:
`inherited` (resolves through the chain, but the body is MAUI's off-platform
no-op, so nothing happens) as distinct from `tizen`. Chaining makes every key
resolve, so a table reporting presence alone would have claimed total parity
while most properties did nothing.
CheckBox.Color added - it exists only after Controls remaps. Label's Controls
keys (FormattedText, TextType, LineBreakMode, MaxLines, TextTransform) are NOT
addressed here: TizenLabelHandler belongs to the core slice and still chains the
Tizen base mapper. Reported rather than edited, to avoid conflicting with an
in-flight file; the parity test deliberately excludes it with that reason stated.
Two new guards, both for a bug class that has now bitten three times. A mapping
declared as Map(TizenXHandler, ...) cannot satisfy Action<IXHandler, ...>, and a
mapping declared inside #if TIZEN does not exist off-platform - in both cases the
name silently binds to MAUI's INHERITED no-op instead. It compiles, and behaviour
differs by target framework. Found this time in TizenEntryHandler.MapBackground
and TizenEditorHandler.MapBackground by the new tests, not by reading.
Also adds an expiry test for the internal ImageSourcePaint gap. The workaround
degrades honestly rather than reflecting over MAUI internals; dotnet/maui#37864
adds a public IImageSourcePaint, and the test asserts the gap still exists so it
FAILS when the contract ships. A workaround that keeps working after its
justification disappears is otherwise invisible.
Not included: the rebase onto core removing its ITizen* shims. Core head ac3dba6
still ships ITizenHandlers.cs, so that remains outstanding. Items 1-3 touch only
Wave A files and do not conflict with it.
Validation: ref-pack and unit lanes clean under CI semantics, 694 tests pass
(full suite run five times to rule out order dependence from the global mapper
mutation), eng/build-workload-free.sh green.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a minimal, additive public contract (Microsoft.Maui.IImageSourcePaint) so out-of-tree platform backends can reliably detect image-based backgrounds and access the underlying IImageSource, without reflection or InternalsVisibleTo, while keeping the built-in ImageSourcePaint implementation internal.
Changes:
- Introduces the new public
IImageSourcePaintinterface in Core and has internalImageSourcePaintimplement it. - Updates in-tree background handling (handlers + platform extensions) and Controls
Brush/Paintconversions to pattern-match the interface instead of the internal concrete type. - Adds Core + Controls unit tests and updates PublicAPI baselines across all TFMs.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/Core/src/ImageSources/IImageSourcePaint.cs | Adds the new public interface contract and docs. |
| src/Core/src/ImageSources/ImageSourcePaint.cs | Implements IImageSourcePaint on the internal paint type. |
| src/Core/src/Handlers/View/ViewHandler.cs | Updates background mapping to detect image paints via the interface. |
| src/Core/src/Handlers/Entry/EntryHandler.iOS.cs | Uses IImageSourcePaint for iOS background mapping. |
| src/Core/src/Handlers/Editor/EditorHandler.iOS.cs | Uses IImageSourcePaint for iOS background mapping. |
| src/Core/src/Platform/iOS/PageExtensions.cs | Updates iOS page background logic to match on the interface. |
| src/Core/src/Platform/Tizen/ViewExtensions.cs | Updates Tizen background logic to match on the interface. |
| src/Controls/src/Core/Brush/Brush.cs | Enables Brush conversion from any IImageSourcePaint (including external implementations). |
| src/Core/tests/UnitTests/ImageSource/ImageSourcePaintContractTests.cs | Adds tests proving contract visibility + external-backend usage shape. |
| src/Controls/tests/Core.UnitTests/ImageBrushTests.cs | Adds tests validating ImageBrush/paint round-tripping and external paint conversion. |
| src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt | Adds new public API entries for IImageSourcePaint. |
| public static (PaintKind Kind, IImageSource ImageSource) Describe(IView view) | ||
| { | ||
| switch (view.Background) | ||
| { | ||
| case IImageSourcePaint imagePaint: | ||
| return (PaintKind.Image, imagePaint.ImageSource); | ||
| case GradientPaint: | ||
| return (PaintKind.Gradient, null); | ||
| case SolidPaint: | ||
| return (PaintKind.Solid, null); | ||
| default: | ||
| return (PaintKind.None, null); | ||
| } | ||
| } |
Architecture review found the backend was not actually substitutable for MAUI's
own handlers: it owned a parallel ITizen*Handler hierarchy and chained only the
Tizen base mapper, so MAUI Controls' RemapForControls dispatch never reached it.
Every claim the redesign rests on was verified against the pinned package
(11.0.0-preview.7.26426.4) before any code changed, because the previous design
was justified by an objection that turned out to be obsolete:
* Microsoft.Maui.Core ships NO Tizen asset - only net11.0, -android, -ios,
-maccatalyst and -windows. A net11.0-tizen11.0 project therefore resolves the
neutral assembly, where every handler interface types PlatformView as object.
The CS9333 alias mismatch that motivated the backend-only interfaces cannot
occur. Confirmed by implementing IButtonHandler explicitly: it compiled.
* ImageSourcePartLoader is public, which is what unblocked IButtonHandler.
* Chaining MAUI's static mapper is LIVE, not a snapshot: a mapper built before
Controls' static constructor runs still picks up the remap. Measured.
* All 39 chained Button mappings dispatch against a backend handler with zero
InvalidCastException. The hard-cast concern is real but satisfied.
So: all fourteen handlers now implement MAUI's real IXHandler, the backend-only
interfaces are gone, and each mapper is composed in three layers - MAUI's static
mapper (carrying Controls' remaps), then the Tizen view mappings over its no-op
bodies, then the handler's own keys, which must win because Entry.Background has
to re-evaluate the container before painting.
Parity is now measured against Controls rather than Core. The test project
references Microsoft.Maui.Controls.Core and forces the static remaps before
reading any mapper; measuring beforehand reported a parity the backend did not
have. The generated matrix gained a third state as a direct consequence:
`inherited` (resolves through the chain, but the body is MAUI's off-platform
no-op, so nothing happens) as distinct from `tizen`. Chaining makes every key
resolve, so a table reporting presence alone would have claimed total parity
while most properties did nothing.
CheckBox.Color added - it exists only after Controls remaps. Label's Controls
keys (FormattedText, TextType, LineBreakMode, MaxLines, TextTransform) are NOT
addressed here: TizenLabelHandler belongs to the core slice and still chains the
Tizen base mapper. Reported rather than edited, to avoid conflicting with an
in-flight file; the parity test deliberately excludes it with that reason stated.
Two new guards, both for a bug class that has now bitten three times. A mapping
declared as Map(TizenXHandler, ...) cannot satisfy Action<IXHandler, ...>, and a
mapping declared inside #if TIZEN does not exist off-platform - in both cases the
name silently binds to MAUI's INHERITED no-op instead. It compiles, and behaviour
differs by target framework. Found this time in TizenEntryHandler.MapBackground
and TizenEditorHandler.MapBackground by the new tests, not by reading.
Also adds an expiry test for the internal ImageSourcePaint gap. The workaround
degrades honestly rather than reflecting over MAUI internals; dotnet/maui#37864
adds a public IImageSourcePaint, and the test asserts the gap still exists so it
FAILS when the contract ships. A workaround that keeps working after its
justification disappears is otherwise invisible.
Not included: the rebase onto core removing its ITizen* shims. Core head ac3dba6
still ships ITizenHandlers.cs, so that remains outstanding. Items 1-3 touch only
Wave A files and do not conflict with it.
Validation: ref-pack and unit lanes clean under CI semantics, 694 tests pass
(full suite run five times to rule out order dependence from the global mapper
mutation), eng/build-workload-free.sh green.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses review feedback that the original change over-promised support for third-party implementations of the new interface. The public read contract is unchanged and still solves the blocker: an external backend can pattern match `IView.Background` and read the image source with no reflection and no internals access. What changed: - Document the interface as consumption-only. Implementing it outside of .NET MAUI is explicitly unsupported, and .NET MAUI reserves the right to add members in future releases. - Revert the in-tree pattern matches back to the internal `ImageSourcePaint`. Custom paints were never reliably honored: specialized handlers such as `LayoutHandler.MapBackground` call `UpdateBackground` directly and bypass the image-source path, and `Brush`'s paint-to-brush conversion only round-trips an `IImageSource` that is a Controls `ImageSource`. Matching the concrete type keeps behavior provably identical to before, making this change purely additive. - Drop the tests asserting that externally-authored paints implementing the interface are supported. The remaining tests prove a fake external backend can pattern match and read MAUI's built-in internal image paint through public API only, without implementing the interface itself. - Document that values arise as a `Paint` from `IView.Background`, that a null `ImageSource` means an image background with nothing to draw, and how this differs from `Microsoft.Maui.Graphics.ImagePaint`, which carries an already-loaded `IImage`. Both are covered by new tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ec0bf62-da2c-49fb-af27-a0a79cbbd6bb
Updated — requesting fresh code + API reviewPushed
What changed
The public surface is unchanged from the first push — still one interface with one read-only getter. Re-validation
Fresh code and API review appreciated — particularly on the consumption-only framing and whether the |
Key presence is cheap to satisfy and proves little, so dispatch the Controls-remapped properties and the focus commands through the real composed mappers on real handlers bound to real Controls views. Each dispatch first asserts the key actually resolves, otherwise UpdateValue on an unknown key is a silent no-op and the test would pass vacuously - verified by removing TizenLabelHandler's chain and watching it fail. TizenLabelHandler is no longer excluded from the reachability theory. Core fixed it in f90ba12: FormattedText, TextType, LineBreakMode, MaxLines and TextTransform all resolve now, with no cast failures, so the exclusion note was stale. Upstream dotnet/maui#37864 is still open, so the image-background workaround stays. Mark the exact adoption point in UpdateBackground instead, with the image-first ordering the fix requires spelled out, and have the expiry test report the shipped shape of IImageSourcePaint.ImageSource so the adopter learns in one run whether the planned pattern still applies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b0524d9-c874-4468-bff6-d21f31c772de
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Core/tests/UnitTests/ImageSource/ImageSourcePaintContractTests.cs:136
- The tuple return type declares
ImageSourceas non-nullableIImageSource, but this method returnsnullfor non-image paints andIImageSourcePaint.ImageSourceis nullable. This creates a nullability mismatch (and can introduce warnings or incorrect assumptions by callers).
public static (PaintKind Kind, IImageSource ImageSource) Describe(IView view)
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
.NET MAUI represents an image background as the internal
Microsoft.Maui.ImageSourcePaint. An out-of-tree platform backend receives backgrounds as aPaintthroughIView.Background, so it has no supported way to recognize an image background or read itsIImageSource.This is a hard blocker for external backends. Reproduced against the released
Microsoft.Maui.Corepackage — an external assembly writing the exact code the in-tree handlers use fails to compile:The only workarounds today are reflection or
InternalsVisibleTo, neither of which is viable for a third-party backend.Solution
Add the smallest possible additive read contract:
The internal
ImageSourcePaintimplements it and stays internal, so no implementation detail (its settable property, its constructors, the concrete type) is exposed. An external backend can now do:Consumption-only contract
This interface is for consuming paints that .NET MAUI produces. Implementing it outside of .NET MAUI is explicitly not supported, and this is stated in the XML docs:
Paintimplementing this interface is not guaranteed to be honored. Specialized handlers bypass the image-source path — for exampleLayoutHandler.MapBackgroundon iOS/Android callsplatformView.UpdateBackground(layout)directly and never checks for an image paint.Brush's paint→brush conversion only round-trips anIImageSourcethat is a ControlsImageSource, so arbitrary image sources would not survive conversion.Because of this, in-tree pattern matches deliberately continue to use the concrete internal
ImageSourcePaint. The diff is therefore purely additive — a new interface, an interface list entry on an internal type, and the PublicAPI entries. No in-tree behavior changes at all.Also documented: values are obtained by pattern matching an existing
Paint(typicallyIView.Background); anullImageSourcemeans an image background with nothing to draw and should clear any previously applied image; and this is distinct fromMicrosoft.Maui.Graphics.ImagePaint, which carries an already-loadedIImagerather than an unresolvedIImageSource.Alternatives considered and rejected:
ImageSourcePaintpublicImageSource, two constructors, and a concrete type we'd be locked into. Far larger surface than needed.InternalsVisibleTofor known backendsCompatibility
Purely additive — nothing is removed, renamed, or rerouted.
PublicAPI.Unshipped.txtfor all 8 TFM folders.ImageBrush⇄ImageSourcePaintround-trips exactly as before.ImageSourcePaintwas never XAML-exposed;ImageBrushis untouched.isinst.ImageSourcePaintdid not and does not override equality.Tests
ImageSourcePaintContractTests(Core, 10 tests) uses a fake external backend that only consumes the contract — it never implements it — and touches only public API, with no reflection and no internals access. It proves that backend can distinguish solid / gradient / image / no paint and read theIImageSourceoff MAUI's built-in internal paint. It also asserts:ImageSourcePaintremains internal,nullImageSourcestill identifies an image background,Graphics.ImagePaintis not anIImageSourcePaint.ImageBrushTests(Controls, 3 tests) coversImageBrush→ paint →ImageBrushround-tripping and that solid/gradient brushes are not image paints.Verified locally:
Core.UnitTests(net11.0): 958 passed, 0 failedControls.Core.UnitTests(net11.0): 6210 passed, 0 failedCore.csprojbuilds clean (0 warnings) fornetstandard2.0,netstandard2.1,net11.0,net11.0-ios,net11.0-maccatalyst,net11.0-android— confirming the PublicAPI entries are correct for every TFM.CS0122now compiles successfully against the reference assembly produced by this branch.dotnet format --verify-no-changesclean on all changed files.Issues Fixed
Unblocks external platform backends (e.g. an out-of-tree Tizen backend) from implementing image-source background rendering.