diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000000..d53a4b34c6 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,14 @@ +# Agent workspace + +This directory is the vendor-neutral home for project agent material. `AGENTS.md` at the repository root is the canonical entrypoint because Codex and several other coding agents recognise it. There is no universal cross-vendor skill directory, so provider entrypoints must explicitly direct agents to the selected skill under `.agents/skills/` when native discovery is unavailable. + +## Layout + +- `instructions/` contains detailed, durable project and subsystem guidance. +- `skills/` contains focused, opt-in workflows. A skill must say when to load it; it is not global context. + +Keep durable project knowledge in `docs/`, and link to it from here. Do not add provider-specific rules here. Provider entrypoints at the repository root and under `.github/` are intentionally tiny pointers to `AGENTS.md`. + +## Ownership + +Update the relevant file here when a convention or workflow changes. Update a provider compatibility file only if that provider requires a particular file name or format to find `AGENTS.md`. diff --git a/.agents/instructions/repository.md b/.agents/instructions/repository.md new file mode 100644 index 0000000000..7ec16aa28b --- /dev/null +++ b/.agents/instructions/repository.md @@ -0,0 +1,165 @@ +# Mapsui repository instructions + +These instructions guide any coding agent working in this repository. + +## Critical: Do not make changes without explicit request +- **Never start editing code, creating files unless the user explicitly asks you to do so.** +- **Never create or switch branches, run `git commit` or `git push`, or run any destructive Git command unless the user explicitly requests that specific action.** +- When the user asks a question, discusses architecture, or explores options — respond with analysis, explanations, or plans only. +- Wait for clear instructions like "implement this", "make this change", "create this file", or "update the code" before touching any files. +- Wait for clear instructions like "create this branch", "switch to this branch", "commit this", "push", or "create a PR" before performing the corresponding Git action. Permission for one action does not authorize another. +- **This rule applies even when investigating CI/build failures.** Diagnosing a build problem means reporting findings and proposing a fix — never applying the fix, committing, or pushing without explicit instruction. +- If unsure whether the user wants changes made, ask first. + +## Project overview +- This repository contains the Mapsui project, a .NET/C# mapping and map-rendering toolkit used across multiple app platforms. +- Typical technologies you will see: C#, .NET, build/test via dotnet CLI, sample apps, and cross-platform UI integrations. + +## Tech and build +- Primary language: C# (.NET). +- Build and test the affected projects according to the validation policy in `AGENTS.md`; do not use an unscoped solution build as the default validation command. +- Full-checklist validation includes the supported Android projects and therefore requires the Android SDK and .NET Android workloads. +- Check formatting with the same `dotnet format ... --verify-no-changes` commands used by CI. +- Respect solution/project configurations and any Directory.Build.props/targets. + +## Style and quality +- Follow the repo's .editorconfig and analyzers where present. +- Prefer readable, consistent naming; small, focused methods; early returns where it improves clarity. +- Keep public APIs stable; avoid breaking changes without prior discussion. +- Add/adjust XML doc comments when modifying public-facing types/members. + +## Code style +Mapsui prefers a **compact code style**. The repository uses `.editorconfig` to enforce these conventions: + +- **Use `var` everywhere** — even for built-in types (`var count = 5;` not `int count = 5;`) +- **Braces are optional** for single-statement blocks (`if (x) DoIt();` is acceptable) +- **Compact object initializers** — members on same line when practical +- **Prefer expression-bodied members** (`=>` syntax) for methods, properties, constructors +- **Private fields**: `_camelCase` prefix (also for consts and statics, no `s_` prefix for statics) +- **File-scoped namespaces**: `namespace Mapsui.Rendering;` + +Run `dotnet format` to apply these rules automatically. + +## Comments +- **Explain why, not what** — comments should capture the reasoning behind code, not describe what the code does (which is visible from reading it). +- Focus on historical context, edge cases, performance considerations, or workarounds that aren't obvious from the code itself. +- Good: `// Use relative coordinates to avoid float precision loss with large EPSG:3857 values` +- Bad: `// Create path from polygon` + +## Contributions agents should optimize for +- Clear, small changes with strong rationale. +- Tests when fixing bugs or adding non-trivial behavior. +- Meaningful comments that explain *why* (see Comments section above). +- Performance awareness in hot paths; avoid allocations and unnecessary LINQ in tight loops. + +## What to avoid +- Introducing dependencies without discussion. +- Leaking secrets, API keys, or credentials. +- Generating code that compiles but lacks tests for critical changes. +- Breaking cross-platform behavior or build configurations. + +## Testing guidance +- Prefer unit tests near the affected assemblies. +- Use existing test patterns and helpers found in the repo. +- Ensure tests are deterministic and fast; no live network or external service calls unless explicitly mocked. + +### Rendering regression tests — important validation step +Any change that affects rendering (styles, renderers, callouts, widgets, etc.) **must** be validated against the rendering regression tests in `Tests/Mapsui.Rendering.Skia.Tests`. These tests render every sample and compare the output pixel-by-pixel against a stored reference image. + +**Run a single sample's regression test** (fastest way to validate a targeted change): +```ps +dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~CalloutSample" +``` +Replace `CalloutSample` with the class name of the affected sample. + +**Run all regression tests:** +```ps +dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "TestSampleAsync" +``` + +**Interpret results:** +- `Passed` — output matches the reference image. +- `Inconclusive` — no reference image exists yet; the test generated one in `GeneratedRegression/`. +- `Failed` — pixel difference exceeded the threshold. Compare: + - Generated: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/GeneratedRegression/` + - Reference: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/OriginalRegression/` + +**Update reference images** after intentional rendering changes: +```ps +.\Scripts\CopyGeneratedImagesOverOriginalImages.ps1 +``` +Then revert any files that were not actually affected by your change (avoid unnecessary binary diffs in git history). + +### Sample registration — automatic via source generator +Samples **do not need to be manually registered**. The `Mapsui.Sample.SourceGenerator` (in `SourceGenerators/`) scans all classes that implement `ISample`, `ISampleBase`, `ISampleTest`, or `IMapViewSample` at build time and generates a `Samples.Register()` method in the assembly. Adding a new sample class that implements one of these interfaces is sufficient — no call to `AllSamples.Register()` is needed. The regression tests in `Mapsui.Rendering.Skia.Tests` pick up samples through the same mechanism. + +### Experimental renderer and text layout +The experimental renderer (`Mapsui.Experimental.Rendering.Skia`) wraps RichTextKit (RTK) inside `SkiaTextLayoutHelper`. RTK remains a required dependency — it provides UAX#14 line breaking, BiDi, and font fallback. When measuring line height, always use `font.Spacing` (ascent + descent + leading) rather than tight glyph bounds. See `.agents/instructions/richtextkit.instructions.md` for full RTK guidance, or load the `.agents/skills/richtextkit/SKILL.md` skill for on-demand reference. + +## After making changes — checklist + +Before making code changes, use the validation assessment in `AGENTS.md`. Load the checklist skill (`.agents/skills/checklist/SKILL.md`) only for partial or full checklist validation. + +## Available skills + +| Skill | When to load | +|---|---| +| `.agents/skills/checklist/SKILL.md` | When `AGENTS.md` selects partial or full checklist validation | +| `.agents/skills/create-pull-request/SKILL.md` | When opening a pull request — covers title prefix conventions, PR type label selection, and the correct tool to use | +| `.agents/skills/regression-tests/SKILL.md` | When working with rendering regression tests: running, updating references, diagnosing failures, or adding experimental-renderer samples | +| `.agents/skills/richtextkit/SKILL.md` | When implementing text rendering features involving word wrap, BiDi/RTL, emoji, font fallback, or multi-style text blocks | + +## Documentation +- Update README or docs when behavior changes or new features are added. +- Keep commit messages and PR descriptions concise and informative (what/why/impact). +- AI-generated design documents (architecture analyses, stage plans, etc.) live in [docs/ai-generated/](../../docs/ai-generated/). Add new ones there. This includes design notes, proposals, working documents, and any file generated during a chat session for reference. When an agent produces a document that is not user-facing documentation or source code — such as a design analysis, a proposal, an upgrade plan, or a discussion summary — it must be placed in `docs/ai-generated/` rather than in the project root or any other location. + +### Finding the right documentation file + +Every Markdown file under `docs/general/markdown/` has a YAML front matter block at the top with a `title` and a `description` field. To discover what is available and where, read the front matter of all files in that folder. The `description` field is a one-sentence summary of the page's content — use it to decide which file to read in full or to link to. + +Example front matter: +```yaml +--- +title: Code Quality Standards +description: Generic coding principles for any repository covering architecture, code style, comments, naming conventions, and the quality bar. +--- +``` + +When adding a new documentation file, always include this front matter block with a concise `description`. + +## Pull requests +- Keep PRs small and focused; link to any related issues. +- Include before/after context when changing behavior or performance. +- Ensure CI passes; run the builds and tests selected by the validation policy before pushing. + +## Security and licensing +- Do not commit secrets. +- Adhere to repository licensing; ensure any copied code is compatible and attributed when needed. + +## Contributor guidelines summary + +The contributor guidelines are split into three focused documents: + +- [Code Quality Standards](../../docs/general/markdown/code-quality-standards.md) — generic coding principles (architecture, style, comments, quality bar) +- [Mapsui Architecture Principles](../../docs/general/markdown/mapsui-architecture-principles.md) — Mapsui-specific decisions (lon/lat, rendering loop, coordinate systems, RenderService) +- [Open Source Contributor Guidelines](../../docs/general/markdown/open-source-contributor-guidelines.md) — PR workflow, issue-first policy, PR titles + +### Move problematic code toward the root +In the Mapsui hierarchy (`DataSource → Fetcher → Layer → Map → MapControl`), keep the core clean. Push `IDisposable`, `async/await`, nullable fields, and exception-throwing code toward the root (surface projects / `MapControl`), not into the core. The core should be simple, predictable, and free of these concerns. + +### Disposability +- Do not make classes `IDisposable` just to hold a Skia resource (`SKSurface`, `SKPaint`, `SKPath`, etc.). +- Renderer-owned resources whose lifetime is tied to the map should be stored in `RenderService` (already `IDisposable`, owned by `Map`). Use `RenderService.GetPersistentRenderSurface(Func ensure)` as the pattern. +- `RenderService.Dispose()` handles cleanup — the renderer itself stays free of `IDisposable`. + +### No rendering in the draw/paint loop +Separate *rendering* (creating platform resources, e.g. `SKPath path = ToSKPath(...)`) from *drawing* (using them on the canvas, e.g. `canvas.DrawPath(...)`). Resources should be prepared before the paint loop, not inside it. + +### lon/lat ordering +Always use **lon, lat** order (consistent with x, y). Example: `SphericalMercator.FromLonLat(lon, lat)`. Prefer named properties (`Longitude`, `Latitude`) when ordering would be ambiguous. + +### Extension methods +- Always in an `Extensions` folder, in a class named `{TypeItExtends}Extensions` (drop the `I` for interfaces: `ILayer` → `LayerExtensions`). +- Namespace follows the folder, not the type being extended. +- Collection extensions for a type live in the same class as the individual-type extensions. diff --git a/.agents/instructions/richtextkit.instructions.md b/.agents/instructions/richtextkit.instructions.md new file mode 100644 index 0000000000..a2a7318057 --- /dev/null +++ b/.agents/instructions/richtextkit.instructions.md @@ -0,0 +1,226 @@ +--- +applyTo: "Mapsui.Rendering.Skia/**,Mapsui.Experimental.Rendering.Skia/**" +--- + +When working in this area, load and read `.agents/skills/richtextkit/SKILL.md` for the full RichTextKit reference (usage patterns, FontMapper, pitfalls, performance notes). + +--- + +## When to use RTK vs plain SkiaSharp + +| Situation | Use | +|---|---| +| Latin text, single font, no word wrap | Plain `SKFont` + `canvas.DrawText()` | +| Word wrap (any script) | RTK `TextBlock` for line breaking | +| RTL / BiDi text | RTK `TextBlock.Paint()` | +| Emoji or font fallback | RTK `TextBlock.Paint()` | +| Mixed fonts/colors in one block | RTK `TextBlock` with multiple `AddText()` calls | + +--- + +## TextBlock — core usage + +```csharp +using Topten.RichTextKit; + +var block = new TextBlock(); +block.AddText("Hello world", new Style { FontFamily = "Arial", FontSize = 16, TextColor = SKColors.Black }); +block.AddText(" bold", new Style { FontFamily = "Arial", FontSize = 16, FontWeight = 700 }); + +block.MaxWidth = 200f; // enables UAX#14 word wrap; null = no wrap +block.MaxHeight = 100f; // truncates at height; null = no limit +block.MaxLines = 3; // alternative to MaxHeight +block.Alignment = TextAlignment.Center; // Left (default), Right, Center, Auto + +block.Layout(); // idempotent; called lazily by property accessors too + +float w = block.MeasuredWidth; +float h = block.MeasuredHeight; // full font metrics: ascent + descent + leading +bool truncated = block.Truncated; + +block.Paint(canvas, new SKPoint(x, y), new TextPaintOptions { Edging = SKFontEdging.Antialias }); +``` + +Key notes: +- `MeasuredHeight` uses full font metrics — the same as `SKFont.Spacing`. Always use `font.Spacing` when computing line height manually so values stay consistent. +- Alignment is baked into glyph positions at layout time. If `MaxWidth` changes after a first layout pass, call `Layout()` again before painting. +- `TextAlignment.Auto` resolves to Left for LTR text, Right for RTL. +- Prefer `TextBlock.Clear()` + re-add over constructing a new instance for frequently-updated text — the internal arrays are reused, reducing GC pressure. + +--- + +## Style — key properties + +```csharp +new Style +{ + FontFamily = "Arial", // default "Arial" + FontSize = 16f, // default 16 + FontWeight = 400, // 400=normal, 700=bold + FontItalic = false, + TextColor = SKColors.Black, + HaloColor = SKColor.Empty, // glow/outline behind glyphs + HaloWidth = 0f, + HaloBlur = 0f, + LineHeight = 1.0f, // line height multiplier + LetterSpacing = 0f, + FontVariant = FontVariant.Normal, // SuperScript, SubScript + TextDirection = TextDirection.Auto, // LTR, RTL, Auto +} +``` + +- `Style.Seal()` — makes immutable; safe to cache and share across blocks. +- `Style.Modify(...)` — returns a new style with selective overrides; does not mutate. + +--- + +## FontMapper — binding RTK to a loaded typeface + +By default RTK resolves typefaces via `SKTypeface.FromFamilyName()`. Override `FontMapper` to point it at an `SKTypeface` already loaded by Mapsui, so line-break measurements match what will actually be drawn: + +```csharp +// MapsuiFontMapper — defined in SkiaTextLayoutHelper.cs +private sealed class MapsuiFontMapper(SKTypeface typeface) : FontMapper +{ + public override SKTypeface TypefaceFromStyle(IStyle style, bool ignoreFontVariants) => typeface; +} + +var block = new TextBlock(); +if (font.Typeface != null) + block.FontMapper = new MapsuiFontMapper(font.Typeface); +block.AddText(text, new Style { FontFamily = font.Typeface?.FamilyName ?? "Arial", FontSize = font.Size }); +``` + +Always set `FontMapper` when using a custom or loaded typeface — a mismatch causes line-break positions to differ from drawn output. + +--- + +## Mapsui usage patterns + +### Line-breaking only (label renderer) + +Use RTK only for UAX#14 word wrap, then draw with plain SkiaSharp: + +```csharp +var block = new TextBlock(); +if (font.Typeface != null) + block.FontMapper = new MapsuiFontMapper(font.Typeface); +block.AddText(line, new Style { FontFamily = font.Typeface?.FamilyName ?? "Arial", FontSize = font.Size }); +block.MaxWidth = maxWidth; +block.Layout(); + +foreach (var textLine in block.Lines) +{ + var lineText = line.Substring(textLine.Start, textLine.Length).TrimEnd(); + result.Add(new Line { Value = lineText, Width = font.MeasureText(lineText, paint) }); +} +// Draw with canvas.DrawText(), not RTK Paint() +``` + +### Full RTK rendering (callout, BiDi, emoji) + +Use RTK for both layout and painting when BiDi or font fallback is needed. See `SkiaTextLayoutHelper.CreateTextBlock` / `PaintTextBlock`: + +```csharp +var block = SkiaTextLayoutHelper.CreateTextBlock(text, font, alignment, color, maxWidth); +SkiaTextLayoutHelper.PaintTextBlock(canvas, block, x, y); +// PaintTextBlock calls: block.Paint(canvas, new SKPoint(x, y), new TextPaintOptions { Edging = SKFontEdging.Antialias }) +``` + +### Double-layout for shared column width (callout title + subtitle) + +Two blocks with different fonts must share a common column width for text alignment to work: + +```csharp +// Pass 1: measure each independently +var titleBlock = CreateTextBlock(title, titleFont, alignment, color, maxWidth); +var subtitleBlock = CreateTextBlock(subtitle, subtitleFont, alignment, color, maxWidth); +var width = Math.Max(titleBlock.MeasuredWidth, subtitleBlock.MeasuredWidth); + +// Pass 2: re-layout at the shared width so alignment is correct +titleBlock.MaxWidth = subtitleBlock.MaxWidth = width; +titleBlock.Layout(); +subtitleBlock.Layout(); +``` + +--- + +## Accessing laid-out lines + +```csharp +foreach (var line in block.Lines) +{ + int start = line.Start; // code-point index into original string + int length = line.Length; // use text.Substring(start, length) + float top = line.YCoord; + float baseline= line.YCoord + line.BaseLine; + float height = line.Height; // ascent + descent + leading + float width = line.Width; // excludes trailing whitespace + var nextLine = line.NextLine; // null on last line +} +``` + +--- + +## Ellipsis and truncation + +```csharp +block.MaxHeight = 60f; // or MaxLines = 3 +block.EllipsisEnabled = true; // default — appends '…' +block.Layout(); +bool wasTruncated = block.Truncated; +``` + +`block.AddEllipsis()` appends '…' to the last line post-layout without re-running the full layout — useful when you want ellipsis but not automatic truncation. + +--- + +## StyleManager + +`StyleManager` caches and deduplicates style instances and provides a Push/Pop stack with a fluent API — useful for building blocks with many mixed style runs: + +```csharp +var sm = new StyleManager(); +sm.DefaultStyle = new Style { FontFamily = "Arial", FontSize = 14 }; + +block.AddText("Normal ", sm.CurrentStyle); +sm.Push(); +block.AddText("Bold ", sm.Bold(true)); +block.AddText("+ italic ", sm.FontItalic(true)); +sm.Pop(); +block.AddText("Normal.", sm.CurrentStyle); +``` + +Per-thread default: `StyleManager.Default`. + +--- + +## Performance + +- Never create `TextBlock` in the draw/paint loop. Cache results in `VectorCache` or `DrawableImageCache`. +- For dynamic text, prefer `block.Clear()` + re-add over creating a new instance — internal arrays are reused. +- `TextPaintOptions.IsAntialias` and `LcdRenderText` are `[Obsolete]` — use `Edging` instead. +- RTK uses HarfBuzzSharp for shaping — cost scales with text complexity, not byte length. + +--- + +## Common pitfalls + +| Pitfall | Fix | +|---|---| +| Center/right alignment renders as left | `MaxWidth` not set, or changed after layout — call `Layout()` again | +| Line height mismatch with manual drawing | Use `font.Spacing`, not tight glyph bounds from `font.MeasureText` | +| BiDi text renders in wrong order | Use `TextBlock.Paint()`, not `canvas.DrawText()` | +| Custom font ignored by RTK line breaker | Set `block.FontMapper` before `AddText()` | +| `Truncated` always false | `MaxHeight` or `MaxLines` must be set | +| Style changes after adding to block | Styles are read at layout time — mutate before `AddText()`, or use `Style.Seal()` | +| `CS0104: 'IStyle' is ambiguous` in `SkiaTextLayoutHelper.cs` | `Topten.RichTextKit.IStyle` clashes with `Mapsui.Styles.IStyle`. Never add `using Mapsui.Styles;` to this file. Use fully-qualified names (`Mapsui.Styles.Font`, `Mapsui.Styles.FontSource`) instead. | + +--- + +## Key files + +- [Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs](../../Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs) — RTK wrappers: `CreateTextBlock`, `PaintTextBlock`, `SplitByWordUnicode`, `MapsuiFontMapper` +- [Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs](../../Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs) — label rendering +- [Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs](../../Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs) — callout rendering (double-layout pattern) +- [Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs](../../Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs) — standard renderer callout (raw RTK API) diff --git a/.agents/skills/checklist/SKILL.md b/.agents/skills/checklist/SKILL.md new file mode 100644 index 0000000000..f5a9115718 --- /dev/null +++ b/.agents/skills/checklist/SKILL.md @@ -0,0 +1,65 @@ +--- +name: checklist +description: Run a selected partial or full validation checklist for Mapsui code changes. Use only when the validation assessment in AGENTS.md selects checklist execution; do not use for a change covered by one obvious targeted test or for documentation-only and agent-tooling-only changes. +--- + +# Checklist + +`AGENTS.md` decides whether to load this skill. Once loaded, run either a partial or full checklist and keep its state in Markdown. + +The user's instructions take precedence. Report the selected scope, its rationale, every check run, and any check that is failed, blocked, or not applicable. + +## Create or resume the state file + +Use `.agents/skills/checklist/state/.md`, replacing `/` and `\` in the branch name with `_`. The state file is local and gitignored. + +Use this table and include only checks selected for the current work: + +```markdown +| Check | Status | Evidence or notes | +|---|---|---| +| Affected builds | TODO | | +| Relevant tests | TODO | | +``` + +Allowed states are `TODO`, `IN PROGRESS`, `PASSED`, `FAILED`, and `N/A`. Set one item to `IN PROGRESS` before running it, then record the result and concise evidence. A failed or blocked check stays `FAILED`; do not present the work as fully validated. + +Resume an existing state file only when it describes the same change. Otherwise replace it with a new checklist for the current work. + +## Partial checklist + +Select the checks that materially validate the change: + +- Build changed projects and affected dependents that are not already built by the selected tests. +- Run the closest relevant test projects, classes, or methods. Running a test project builds its project-reference dependencies. +- For rendering changes, load the `regression-tests` skill and run the relevant rendering test scope. +- Check formatting, documentation, public API compatibility, or architecture only when the change can affect them. +- Review the final diff for correctness and unintended changes. + +If coverage becomes uncertain, tests fail unexpectedly, or the change expands across projects or platforms, convert the state file to the full checklist. + +## Full checklist + +Include every item below. A missing local prerequisite is setup work, not a reason to silently omit a check. + +1. **Environment** — Confirm the SDK selected by `global.json` works. Ensure required workloads and platform SDKs are installed, including the .NET Android workload, Android SDK, and a compatible Java SDK. If Android dependencies are missing, use .NET for Android's `InstallAndroidDependencies` target against a supported Android project, accept the SDK licenses, and set user-level `ANDROID_HOME` and `JAVA_HOME` to the installed locations. Do not commit machine-specific SDK paths. Use the setup reflected in `.github/workflows/dotnet.yml` when local setup is incomplete. +2. **Affected builds** — Build every changed project and its affected dependents. +3. **Android builds** — Always build the supported Android surfaces in Debug: + - `Mapsui.UI.Android/Mapsui.UI.Android.csproj` + - `Mapsui.UI.Maui/Mapsui.UI.Maui.csproj` for `net9.0-android` + - `Samples/Mapsui.Samples.Droid/Mapsui.Samples.Droid.csproj` + - `Samples/Avalonia/Mapsui.Samples.Avalonia.Android/Mapsui.Samples.Avalonia.Android.csproj` + - `Samples/Mapsui.Samples.Maui/Mapsui.Samples.Maui.csproj` for `net9.0-android` + - `Samples/Mapsui.Samples.Maui.MapView/Mapsui.Samples.Maui.MapView.csproj` for `net9.0-android` + Do not include the Avalonia 12 Android sample until its project is moved to its required .NET 10 target and restored to the solution. +4. **Unit tests** — Build and run all applicable test projects, excluding rendering regression tests from this item. Every executed test must pass. +5. **Rendering regression tests** — Include when rendering may be affected. Load the `regression-tests` skill; targeted coverage is acceptable only when it completely covers the change. +6. **Formatting** — Run the CI-equivalent whitespace and style checks: + - `dotnet format whitespace Mapsui.slnx --verbosity normal --verify-no-changes` + - `dotnet format style Mapsui.slnx --verbosity normal --verify-no-changes` +7. **Project rules** — Review the diff against `.agents/instructions/repository.md`, including public API documentation, lon/lat ordering, rendering-loop constraints, and extension-method placement. +8. **Documentation and compatibility** — Update affected documentation and the upgrade guide for public API or observable breaking changes. +9. **Repository hygiene** — Check changed files for unresolved `TODO`, `FIXME`, and `HACK` markers, secrets, accidental binaries, and unrelated edits. +10. **Final review** — Review the complete diff for correctness, edge cases, naming, architecture, cross-platform behavior, and adequate test coverage. + +When a fix is made after a check, rerun that check and every downstream check the fix could affect. Mark the checklist complete only when every selected item is `PASSED` or genuinely `N/A`. diff --git a/.agents/skills/checklist/state/.gitignore b/.agents/skills/checklist/state/.gitignore new file mode 100644 index 0000000000..fb99835b5a --- /dev/null +++ b/.agents/skills/checklist/state/.gitignore @@ -0,0 +1,3 @@ +# Checklist state is local per developer and branch. +*.md +*.json diff --git a/.agents/skills/create-pull-request/SKILL.md b/.agents/skills/create-pull-request/SKILL.md new file mode 100644 index 0000000000..12468f2912 --- /dev/null +++ b/.agents/skills/create-pull-request/SKILL.md @@ -0,0 +1,101 @@ +--- +name: create-pull-request +description: Create a normal or native stacked Mapsui GitHub pull request with the gh CLI, using the repository title and label conventions. Load when the user asks to open or create a pull request. +--- + +# Create a pull request + +Use only the `gh` CLI for GitHub operations in this workflow. Do not use an MCP server or IDE-specific pull-request tool. + +## Preconditions + +The user must explicitly request creation of the pull request. That request authorizes only pull-request creation. Do not create or switch branches, commit, or push unless the user separately and explicitly requests the specific action. + +Expect the user to have committed and pushed the intended changes to the current remote branch before invoking this skill. Verify that state; do not alter Git history or the local or remote branch to prepare it for the PR. + +Before creating the PR: + +1. Run `gh auth status` and stop with a clear explanation if authentication is unavailable. +2. Determine the current branch and verify that the corresponding remote branch exists. +3. Compare `git log origin/ --oneline -10` with the intended changes. If the remote branch does not contain them, stop and ask the user to commit or push the changes themselves. Do not offer to perform those actions as part of PR creation. +4. Check whether a PR already exists with `gh pr view --json number,url,state,title`. + +## Decide whether the PR is stacked + +Do not assume that the repository's default branch is the correct base. Inspect the remote commit ancestry and open PRs before creating anything. + +Create a native GitHub stack when either: + +- the user explicitly requests a stacked PR; or +- the head branch is based on the head branch of an open lower PR, so comparing the head directly with the trunk repeats commits already represented by that lower PR. + +Useful checks include: + +```powershell +gh api repos/Mapsui/Mapsui/compare/... +gh api repos/Mapsui/Mapsui/commits//pulls -H "Accept: application/vnd.github+json" +``` + +Commit ancestry and an open lower PR establish a stack dependency. Mere overlap in filenames does not. Use a normal PR when the branch descends directly from the intended base or the lower work is already merged. If the dependency remains ambiguous, ask the user before creating the PR because native stacks have different review and merge behavior. + +## Title and label + +Use an imperative title under approximately 72 characters: + +```text +: +``` + +| Prefix | PR type label | Use for | +|---|---|---| +| `fix:` | `PR type: � Fix` | Bug fixes | +| `feat:` | `PR type: 🚀 Feature` | User-facing features | +| `refactor:` | `PR type: ♻� Refactor` | Internal restructuring without behavior changes | +| `update:` | `PR type: 📦 Update` | Dependency or SDK updates | +| `perf:` | `PR type:⚡Performance` | Measurable performance improvements | +| `chore:` | `PR type: 🛠� Chore` | Internal maintenance | +| `ci:` | `PR type: ⚙� Infrastructure` | CI and infrastructure changes | +| `sample:` | `PR type: 🧪 Sample` | Sample changes | +| `test:` | `PR type: ✅ Tests` | Test-only changes | +| `docs:` | `PR type: � Documentation` | Documentation changes | + +Use `feat:`, not `feature:`. Add `☢� Experimental` only when users must opt into an experimental package or mode. + +## Create a normal PR + +Write a concise body describing what changed, why, validation performed, and related issues using `Fixes #NNN` or `Relates to #NNN` where applicable. + +Create the PR with: + +```powershell +gh pr create --repo Mapsui/Mapsui --base main --head --title "" --body "<body>" --label "<PR type label>" +``` + +Add `--draft` only when the user requests a draft or the work is explicitly incomplete. After creation, run `gh pr view --json number,url,title,state,labels` and report the resulting URL and metadata. + +## Create or extend a native stacked PR + +GitHub's native stack is more than a chain of ordinary PRs with dependent base branches. The PRs must also be linked through GitHub's stack API. Setting the base correctly without linking leaves GitHub showing that the PRs *can be stacked* rather than displaying the stack map. + +Use only the `link` operation from GitHub's official `gh-stack` extension for this repository workflow. Do not use `gh stack init`, `add`, `submit`, `push`, `rebase`, or `sync`; those commands can create, switch, rewrite, or push branches, which PR creation does not authorize. + +1. Identify the complete ordered chain of open PRs, bottom to top. Every branch must be in `Mapsui/Mapsui`, and each upper branch must descend linearly from the branch below it. +2. For a new upper layer, create it with `gh pr create`, using the head branch of the PR immediately below it as `--base`. Keep the same title, body, label, and draft rules used for a normal PR. +3. Confirm the extension is available with `gh stack --help`. If it is unavailable, stop and ask the user to install or authorize installation of `github/gh-stack`; installing an extension is not implied by a PR request. +4. Link existing PR numbers in bottom-to-top order: + +```powershell +gh stack link <bottom-pr-number> <next-pr-number> <top-pr-number> +``` + +When extending an existing stack, pass its full PR chain or use the existing stack number followed by the new upper PR. Passing PR numbers uses the remote API and does not create or push branches. Use `--open` only when the user wants draft PRs changed to ready for review. + +5. Verify every PR through the REST API: + +```powershell +gh api repos/Mapsui/Mapsui/pulls/<pr-number> --jq '{number:.number,stack:.stack,base:.base.ref,head:.head.ref}' +``` + +Every layer must report the same `stack.id` and `stack.number`, the expected contiguous `stack.position`, and the same `stack.size`. Report the stack number, layer order, and PR URLs. If GitHub still offers to turn the chain into a stack, the workflow is not complete. + +GitHub's native stacked-PR feature is in public preview. When the installed extension's behavior differs from these instructions, consult the current [GitHub stacked pull requests documentation](https://docs.github.com/en/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests) before mutating PR state. diff --git a/.github/skills/regression-tests/SKILL.md b/.agents/skills/regression-tests/SKILL.md similarity index 98% rename from .github/skills/regression-tests/SKILL.md rename to .agents/skills/regression-tests/SKILL.md index 37c99906fa..e67171cfc7 100644 --- a/.github/skills/regression-tests/SKILL.md +++ b/.agents/skills/regression-tests/SKILL.md @@ -138,7 +138,7 @@ The project `.csproj` must have a matching `<EmbeddedResource>` entry. Mismatch ## Adding a new sample with a regression test -1. Implement the sample (see copilot-instructions for auto-registration via source generator). +1. Implement the sample (see `.agents/instructions/repository.md` for auto-registration via source generator). 2. Run `dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~MySample"`. 3. Result should be **Inconclusive** (no reference yet). 4. **Visually inspect** the generated image in `GeneratedRegression/`. diff --git a/.github/skills/richtextkit/SKILL.md b/.agents/skills/richtextkit/SKILL.md similarity index 100% rename from .github/skills/richtextkit/SKILL.md rename to .agents/skills/richtextkit/SKILL.md diff --git a/.editorconfig b/.editorconfig index 7197ccb43e..5815b9f5bb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -264,6 +264,8 @@ dotnet_diagnostic.IDE0059.severity = warning # IDE0059: Unnecessary assignment o dotnet_diagnostic.CA2208.severity = silent # CA2208: Instantiate argument exceptions correctly dotnet_diagnostic.VSTHRD200.severity = warning # VSTHRD200: Use "Async" suffix for async methods dotnet_diagnostic.CS0184.severity = error # CS0184: 'is' expression's given expression is never of the provided type +dotnet_diagnostic.CA1311.severity = error # CA1311: Specify a culture or use an invariant version - Why? A culture-sensitive ToLower() turns 'I' into a dotless 'ı' under Turkish culture, see #3383. +dotnet_diagnostic.CA1310.severity = error # CA1310: Specify StringComparison for correctness - Why? Culture-sensitive StartsWith/EndsWith/Compare depend on the user's locale, see #3383. dotnet_diagnostic.IDE2000.severity = suggestion # Multiple blank lines dotnet_style_allow_multiple_blank_lines_experimental = false # Needed for IDE2000 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1113f3ed22..86a87dd8d5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,164 +1,5 @@ -# Copilot Instructions for this Repository +# Mapsui agent instructions -These instructions guide GitHub Copilot (and Copilot Chat) when helping in this repository. +This is a compatibility entrypoint for GitHub Copilot. -## Critical: Do not make changes without explicit request -- **Never start editing code, creating files unless the user explicitly asks you to do so.** -- **Never run `git commit`, `git push`, or any destructive git command unless the user explicitly asks.** -- When the user asks a question, discusses architecture, or explores options — respond with analysis, explanations, or plans only. -- Wait for clear instructions like "implement this", "make this change", "create this file", or "update the code" before touching any files. -- Wait for clear instructions like "commit this", "push", or "create a PR" before running any git commands. -- **This rule applies even when investigating CI/build failures.** Diagnosing a build problem means reporting findings and proposing a fix — never applying the fix, committing, or pushing without explicit instruction. -- If unsure whether the user wants changes made, ask first. - -## Project overview -- This repository contains the Mapsui project, a .NET/C# mapping and map-rendering toolkit used across multiple app platforms. -- Typical technologies you will see: C#, .NET, build/test via dotnet CLI, sample apps, and cross-platform UI integrations. - -## Tech and build -- Primary language: C# (.NET). -- Build locally: `dotnet restore && dotnet build` from the repository root. -- Run tests: `dotnet test` from the repository root (or relevant test project directories). -- Respect solution/project configurations and any Directory.Build.props/targets. - -## Style and quality -- Follow the repo's .editorconfig and analyzers where present. -- Prefer readable, consistent naming; small, focused methods; early returns where it improves clarity. -- Keep public APIs stable; avoid breaking changes without prior discussion. -- Add/adjust XML doc comments when modifying public-facing types/members. - -## Code style -Mapsui prefers a **compact code style**. The repository uses `.editorconfig` to enforce these conventions: - -- **Use `var` everywhere** — even for built-in types (`var count = 5;` not `int count = 5;`) -- **Braces are optional** for single-statement blocks (`if (x) DoIt();` is acceptable) -- **Compact object initializers** — members on same line when practical -- **Prefer expression-bodied members** (`=>` syntax) for methods, properties, constructors -- **Private fields**: `_camelCase` prefix (also for consts and statics, no `s_` prefix for statics) -- **File-scoped namespaces**: `namespace Mapsui.Rendering;` - -Run `dotnet format` to apply these rules automatically. - -## Comments -- **Explain why, not what** — comments should capture the reasoning behind code, not describe what the code does (which is visible from reading it). -- Focus on historical context, edge cases, performance considerations, or workarounds that aren't obvious from the code itself. -- Good: `// Use relative coordinates to avoid float precision loss with large EPSG:3857 values` -- Bad: `// Create path from polygon` - -## Contributions Copilot should optimize for -- Clear, small changes with strong rationale. -- Tests when fixing bugs or adding non-trivial behavior. -- Meaningful comments that explain *why* (see Comments section above). -- Performance awareness in hot paths; avoid allocations and unnecessary LINQ in tight loops. - -## What to avoid -- Introducing dependencies without discussion. -- Leaking secrets, API keys, or credentials. -- Generating code that compiles but lacks tests for critical changes. -- Breaking cross-platform behavior or build configurations. - -## Testing guidance -- Prefer unit tests near the affected assemblies. -- Use existing test patterns and helpers found in the repo. -- Ensure tests are deterministic and fast; no live network or external service calls unless explicitly mocked. - -### Rendering regression tests — important validation step -Any change that affects rendering (styles, renderers, callouts, widgets, etc.) **must** be validated against the rendering regression tests in `Tests/Mapsui.Rendering.Skia.Tests`. These tests render every sample and compare the output pixel-by-pixel against a stored reference image. - -**Run a single sample's regression test** (fastest way to validate a targeted change): -```ps -dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~CalloutSample" -``` -Replace `CalloutSample` with the class name of the affected sample. - -**Run all regression tests:** -```ps -dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "TestSampleAsync" -``` - -**Interpret results:** -- `Passed` — output matches the reference image. -- `Inconclusive` — no reference image exists yet; the test generated one in `GeneratedRegression/`. -- `Failed` — pixel difference exceeded the threshold. Compare: - - Generated: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/GeneratedRegression/` - - Reference: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/OriginalRegression/` - -**Update reference images** after intentional rendering changes: -```ps -.\Scripts\CopyGeneratedImagesOverOriginalImages.ps1 -``` -Then revert any files that were not actually affected by your change (avoid unnecessary binary diffs in git history). - -### Sample registration — automatic via source generator -Samples **do not need to be manually registered**. The `Mapsui.Sample.SourceGenerator` (in `SourceGenerators/`) scans all classes that implement `ISample`, `ISampleBase`, `ISampleTest`, or `IMapViewSample` at build time and generates a `Samples.Register()` method in the assembly. Adding a new sample class that implements one of these interfaces is sufficient — no call to `AllSamples.Register()` is needed. The regression tests in `Mapsui.Rendering.Skia.Tests` pick up samples through the same mechanism. - -### Experimental renderer and text layout -The experimental renderer (`Mapsui.Experimental.Rendering.Skia`) wraps RichTextKit (RTK) inside `SkiaTextLayoutHelper`. RTK remains a required dependency — it provides UAX#14 line breaking, BiDi, and font fallback. When measuring line height, always use `font.Spacing` (ascent + descent + leading) rather than tight glyph bounds. See `.github/instructions/richtextkit.instructions.md` for full RTK guidance, or load the `.github/skills/richtextkit/SKILL.md` skill for on-demand reference. - -## After making changes — checklist - -Before opening a pull request, load the done-checklist skill (`.github/skills/done-checklist/SKILL.md`) and work through every task. The skill tracks state in a per-branch JSON file so nothing is skipped across sessions. - -## Available skills - -| Skill | When to load | -|---|---| -| `.github/skills/done-checklist/SKILL.md` | Before opening any PR — runs the full pre-PR checklist | -| `.github/skills/create-pr/SKILL.md` | When opening a pull request — covers title prefix conventions, PR type label selection, and the correct tool to use | -| `.github/skills/regression-tests/SKILL.md` | When working with rendering regression tests: running, updating references, diagnosing failures, or adding experimental-renderer samples | -| `.github/skills/richtextkit/SKILL.md` | When implementing text rendering features involving word wrap, BiDi/RTL, emoji, font fallback, or multi-style text blocks | - -## Documentation -- Update README or docs when behavior changes or new features are added. -- Keep commit messages and PR descriptions concise and informative (what/why/impact). -- AI-generated design documents (architecture analyses, stage plans, etc.) live in [docs/ai-generated/](../docs/ai-generated/). Add new ones there. This includes design notes, proposals, working documents, and any file generated during a chat session for reference. When Copilot produces a document that is not user-facing documentation or source code — such as a design analysis, a proposal, an upgrade plan, or a discussion summary — it must be placed in `docs/ai-generated/` rather than in the project root or any other location. - -### Finding the right documentation file - -Every Markdown file under `docs/general/markdown/` has a YAML front matter block at the top with a `title` and a `description` field. To discover what is available and where, read the front matter of all files in that folder. The `description` field is a one-sentence summary of the page's content — use it to decide which file to read in full or to link to. - -Example front matter: -```yaml ---- -title: Code Quality Standards -description: Generic coding principles for any repository covering architecture, code style, comments, naming conventions, and the quality bar. ---- -``` - -When adding a new documentation file, always include this front matter block with a concise `description`. - -## Pull requests -- Keep PRs small and focused; link to any related issues. -- Include before/after context when changing behavior or performance. -- Ensure CI passes; run `dotnet build`/`dotnet test` locally before pushing. - -## Security and licensing -- Do not commit secrets. -- Adhere to repository licensing; ensure any copied code is compatible and attributed when needed. - -## Contributor guidelines summary - -The contributor guidelines are split into three focused documents: - -- [Code Quality Standards](../docs/general/markdown/code-quality-standards.md) — generic coding principles (architecture, style, comments, quality bar) -- [Mapsui Architecture Principles](../docs/general/markdown/mapsui-architecture-principles.md) — Mapsui-specific decisions (lon/lat, rendering loop, coordinate systems, RenderService) -- [Open Source Contributor Guidelines](../docs/general/markdown/open-source-contributor-guidelines.md) — PR workflow, issue-first policy, PR titles - -### Move problematic code toward the root -In the Mapsui hierarchy (`DataSource → Fetcher → Layer → Map → MapControl`), keep the core clean. Push `IDisposable`, `async/await`, nullable fields, and exception-throwing code toward the root (surface projects / `MapControl`), not into the core. The core should be simple, predictable, and free of these concerns. - -### Disposability -- Do not make classes `IDisposable` just to hold a Skia resource (`SKSurface`, `SKPaint`, `SKPath`, etc.). -- Renderer-owned resources whose lifetime is tied to the map should be stored in `RenderService` (already `IDisposable`, owned by `Map`). Use `RenderService.GetPersistentRenderSurface(Func<object?, object> ensure)` as the pattern. -- `RenderService.Dispose()` handles cleanup — the renderer itself stays free of `IDisposable`. - -### No rendering in the draw/paint loop -Separate *rendering* (creating platform resources, e.g. `SKPath path = ToSKPath(...)`) from *drawing* (using them on the canvas, e.g. `canvas.DrawPath(...)`). Resources should be prepared before the paint loop, not inside it. - -### lon/lat ordering -Always use **lon, lat** order (consistent with x, y). Example: `SphericalMercator.FromLonLat(lon, lat)`. Prefer named properties (`Longitude`, `Latitude`) when ordering would be ambiguous. - -### Extension methods -- Always in an `Extensions` folder, in a class named `{TypeItExtends}Extensions` (drop the `I` for interfaces: `ILayer` → `LayerExtensions`). -- Namespace follows the folder, not the type being extended. -- Collection extensions for a type live in the same class as the individual-type extensions. +@../AGENTS.md diff --git a/.github/instructions/richtextkit.instructions.md b/.github/instructions/richtextkit.instructions.md index 9a168178ed..13108a3790 100644 --- a/.github/instructions/richtextkit.instructions.md +++ b/.github/instructions/richtextkit.instructions.md @@ -2,225 +2,4 @@ applyTo: "Mapsui.Rendering.Skia/**,Mapsui.Experimental.Rendering.Skia/**" --- -When working in this area, load and read `.github/skills/richtextkit/SKILL.md` for the full RichTextKit reference (usage patterns, FontMapper, pitfalls, performance notes). - ---- - -## When to use RTK vs plain SkiaSharp - -| Situation | Use | -|---|---| -| Latin text, single font, no word wrap | Plain `SKFont` + `canvas.DrawText()` | -| Word wrap (any script) | RTK `TextBlock` for line breaking | -| RTL / BiDi text | RTK `TextBlock.Paint()` | -| Emoji or font fallback | RTK `TextBlock.Paint()` | -| Mixed fonts/colors in one block | RTK `TextBlock` with multiple `AddText()` calls | - ---- - -## TextBlock — core usage - -```csharp -using Topten.RichTextKit; - -var block = new TextBlock(); -block.AddText("Hello world", new Style { FontFamily = "Arial", FontSize = 16, TextColor = SKColors.Black }); -block.AddText(" bold", new Style { FontFamily = "Arial", FontSize = 16, FontWeight = 700 }); - -block.MaxWidth = 200f; // enables UAX#14 word wrap; null = no wrap -block.MaxHeight = 100f; // truncates at height; null = no limit -block.MaxLines = 3; // alternative to MaxHeight -block.Alignment = TextAlignment.Center; // Left (default), Right, Center, Auto - -block.Layout(); // idempotent; called lazily by property accessors too - -float w = block.MeasuredWidth; -float h = block.MeasuredHeight; // full font metrics: ascent + descent + leading -bool truncated = block.Truncated; - -block.Paint(canvas, new SKPoint(x, y), new TextPaintOptions { Edging = SKFontEdging.Antialias }); -``` - -Key notes: -- `MeasuredHeight` uses full font metrics — the same as `SKFont.Spacing`. Always use `font.Spacing` when computing line height manually so values stay consistent. -- Alignment is baked into glyph positions at layout time. If `MaxWidth` changes after a first layout pass, call `Layout()` again before painting. -- `TextAlignment.Auto` resolves to Left for LTR text, Right for RTL. -- Prefer `TextBlock.Clear()` + re-add over constructing a new instance for frequently-updated text — the internal arrays are reused, reducing GC pressure. - ---- - -## Style — key properties - -```csharp -new Style -{ - FontFamily = "Arial", // default "Arial" - FontSize = 16f, // default 16 - FontWeight = 400, // 400=normal, 700=bold - FontItalic = false, - TextColor = SKColors.Black, - HaloColor = SKColor.Empty, // glow/outline behind glyphs - HaloWidth = 0f, - HaloBlur = 0f, - LineHeight = 1.0f, // line height multiplier - LetterSpacing = 0f, - FontVariant = FontVariant.Normal, // SuperScript, SubScript - TextDirection = TextDirection.Auto, // LTR, RTL, Auto -} -``` - -- `Style.Seal()` — makes immutable; safe to cache and share across blocks. -- `Style.Modify(...)` — returns a new style with selective overrides; does not mutate. - ---- - -## FontMapper — binding RTK to a loaded typeface - -By default RTK resolves typefaces via `SKTypeface.FromFamilyName()`. Override `FontMapper` to point it at an `SKTypeface` already loaded by Mapsui, so line-break measurements match what will actually be drawn: - -```csharp -// MapsuiFontMapper — defined in SkiaTextLayoutHelper.cs -private sealed class MapsuiFontMapper(SKTypeface typeface) : FontMapper -{ - public override SKTypeface TypefaceFromStyle(IStyle style, bool ignoreFontVariants) => typeface; -} - -var block = new TextBlock(); -if (font.Typeface != null) - block.FontMapper = new MapsuiFontMapper(font.Typeface); -block.AddText(text, new Style { FontFamily = font.Typeface?.FamilyName ?? "Arial", FontSize = font.Size }); -``` - -Always set `FontMapper` when using a custom or loaded typeface — a mismatch causes line-break positions to differ from drawn output. - ---- - -## Mapsui usage patterns - -### Line-breaking only (label renderer) - -Use RTK only for UAX#14 word wrap, then draw with plain SkiaSharp: - -```csharp -var block = new TextBlock(); -if (font.Typeface != null) - block.FontMapper = new MapsuiFontMapper(font.Typeface); -block.AddText(line, new Style { FontFamily = font.Typeface?.FamilyName ?? "Arial", FontSize = font.Size }); -block.MaxWidth = maxWidth; -block.Layout(); - -foreach (var textLine in block.Lines) -{ - var lineText = line.Substring(textLine.Start, textLine.Length).TrimEnd(); - result.Add(new Line { Value = lineText, Width = font.MeasureText(lineText, paint) }); -} -// Draw with canvas.DrawText(), not RTK Paint() -``` - -### Full RTK rendering (callout, BiDi, emoji) - -Use RTK for both layout and painting when BiDi or font fallback is needed. See `SkiaTextLayoutHelper.CreateTextBlock` / `PaintTextBlock`: - -```csharp -var block = SkiaTextLayoutHelper.CreateTextBlock(text, font, alignment, color, maxWidth); -SkiaTextLayoutHelper.PaintTextBlock(canvas, block, x, y); -// PaintTextBlock calls: block.Paint(canvas, new SKPoint(x, y), new TextPaintOptions { Edging = SKFontEdging.Antialias }) -``` - -### Double-layout for shared column width (callout title + subtitle) - -Two blocks with different fonts must share a common column width for text alignment to work: - -```csharp -// Pass 1: measure each independently -var titleBlock = CreateTextBlock(title, titleFont, alignment, color, maxWidth); -var subtitleBlock = CreateTextBlock(subtitle, subtitleFont, alignment, color, maxWidth); -var width = Math.Max(titleBlock.MeasuredWidth, subtitleBlock.MeasuredWidth); - -// Pass 2: re-layout at the shared width so alignment is correct -titleBlock.MaxWidth = subtitleBlock.MaxWidth = width; -titleBlock.Layout(); -subtitleBlock.Layout(); -``` - ---- - -## Accessing laid-out lines - -```csharp -foreach (var line in block.Lines) -{ - int start = line.Start; // code-point index into original string - int length = line.Length; // use text.Substring(start, length) - float top = line.YCoord; - float baseline= line.YCoord + line.BaseLine; - float height = line.Height; // ascent + descent + leading - float width = line.Width; // excludes trailing whitespace - var nextLine = line.NextLine; // null on last line -} -``` - ---- - -## Ellipsis and truncation - -```csharp -block.MaxHeight = 60f; // or MaxLines = 3 -block.EllipsisEnabled = true; // default — appends '…' -block.Layout(); -bool wasTruncated = block.Truncated; -``` - -`block.AddEllipsis()` appends '…' to the last line post-layout without re-running the full layout — useful when you want ellipsis but not automatic truncation. - ---- - -## StyleManager - -`StyleManager` caches and deduplicates style instances and provides a Push/Pop stack with a fluent API — useful for building blocks with many mixed style runs: - -```csharp -var sm = new StyleManager(); -sm.DefaultStyle = new Style { FontFamily = "Arial", FontSize = 14 }; - -block.AddText("Normal ", sm.CurrentStyle); -sm.Push(); -block.AddText("Bold ", sm.Bold(true)); -block.AddText("+ italic ", sm.FontItalic(true)); -sm.Pop(); -block.AddText("Normal.", sm.CurrentStyle); -``` - -Per-thread default: `StyleManager.Default`. - ---- - -## Performance - -- Never create `TextBlock` in the draw/paint loop. Cache results in `VectorCache` or `DrawableImageCache`. -- For dynamic text, prefer `block.Clear()` + re-add over creating a new instance — internal arrays are reused. -- `TextPaintOptions.IsAntialias` and `LcdRenderText` are `[Obsolete]` — use `Edging` instead. -- RTK uses HarfBuzzSharp for shaping — cost scales with text complexity, not byte length. - ---- - -## Common pitfalls - -| Pitfall | Fix | -|---|---| -| Center/right alignment renders as left | `MaxWidth` not set, or changed after layout — call `Layout()` again | -| Line height mismatch with manual drawing | Use `font.Spacing`, not tight glyph bounds from `font.MeasureText` | -| BiDi text renders in wrong order | Use `TextBlock.Paint()`, not `canvas.DrawText()` | -| Custom font ignored by RTK line breaker | Set `block.FontMapper` before `AddText()` | -| `Truncated` always false | `MaxHeight` or `MaxLines` must be set | -| Style changes after adding to block | Styles are read at layout time — mutate before `AddText()`, or use `Style.Seal()` | -| `CS0104: 'IStyle' is ambiguous` in `SkiaTextLayoutHelper.cs` | `Topten.RichTextKit.IStyle` clashes with `Mapsui.Styles.IStyle`. Never add `using Mapsui.Styles;` to this file. Use fully-qualified names (`Mapsui.Styles.Font`, `Mapsui.Styles.FontSource`) instead. | - ---- - -## Key files - -- [Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs](../../Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs) — RTK wrappers: `CreateTextBlock`, `PaintTextBlock`, `SplitByWordUnicode`, `MapsuiFontMapper` -- [Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs](../../Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs) — label rendering -- [Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs](../../Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs) — callout rendering (double-layout pattern) -- [Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs](../../Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs) — standard renderer callout (raw RTK API) +This is a GitHub Copilot compatibility entrypoint. Read and follow `AGENTS.md`, then load `.agents/instructions/richtextkit.instructions.md` and `.agents/skills/richtextkit/SKILL.md` before working in this area. diff --git a/.github/pages-v4-snapshot.json b/.github/pages-v4-snapshot.json new file mode 100644 index 0000000000..07bc8b2792 --- /dev/null +++ b/.github/pages-v4-snapshot.json @@ -0,0 +1,9 @@ +{ + "releaseTag": "pages-v4-frozen-2026-07-26", + "releaseTagCommit": "d2d770919dd1b84b0f594fa7a8d23adf5efd3916", + "assetName": "mapsui-v4-site.tar.gz", + "sourceRef": "gh-pages:v4", + "sourceCommit": "697b9a4bd790b3572bb8b4278e45c0b7e633f8de", + "sha256": "4d2bc8b58df9a8523f95203a8dddf5ca7a63895e25cf767d6b603602d30b78e1", + "fileCount": 1114 +} diff --git a/.github/pages-v4-snapshot.md b/.github/pages-v4-snapshot.md new file mode 100644 index 0000000000..265d1fb05f --- /dev/null +++ b/.github/pages-v4-snapshot.md @@ -0,0 +1,35 @@ +# Frozen v4 website + +The v4 documentation and samples are preserved as an immutable GitHub release +asset. The Pages workflow downloads this snapshot and adds it to `website/v4`; +it never rebuilds the `develop/4.1` branch. + +The release, source commit, checksum, and expected file count are pinned in +`pages-v4-snapshot.json`. The source commit is the last verified `gh-pages` +deployment before the migration to native GitHub Pages artifacts. + +The release tag intentionally points to a parentless, empty metadata commit, +not to a commit in `main`. This prevents the infrastructure tag from being +selected by Git-based package-version calculation. Keep future frozen-site +release tags outside the `main` history for the same reason. + +The snapshot was created with Git checkout conversion disabled so its contents +match the blobs published by GitHub: + +```powershell +git -c core.autocrlf=false -c core.eol=lf archive ` + --format=tar.gz ` + --prefix=v4/ ` + --output=mapsui-v4-site.tar.gz ` + 697b9a4bd790b3572bb8b4278e45c0b7e633f8de:v4 +``` + +After validating the archive checksum, `AddFrozenV4Website.ps1` corrects six +hard-coded `mapsui.com` URLs that predate the `/v4` prefix. These corrections +keep the frozen landing page, documentation, samples, and displayed sample code +within `/v4`; they do not rebuild v4. The assembly validation also rejects the +legacy root directories `/documentation`, `/samples`, and `/api`. + +Replacing the snapshot is an exceptional migration operation. A replacement +must be reviewed, published under a new release tag, and accompanied by an +updated manifest. diff --git a/.github/skills/create-pr/SKILL.md b/.github/skills/create-pr/SKILL.md deleted file mode 100644 index 10f13e0add..0000000000 --- a/.github/skills/create-pr/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: create-pr -description: Create a GitHub Pull Request for Mapsui — covering title prefix conventions, PR type label selection, and the correct tool to use. Load this skill when you are ready to open a pull request. ---- - -# Creating a Pull Request in Mapsui - -Use this skill when you are ready to open a pull request. It covers the title format, label selection, and the tool to use. - ---- - -## 0 — Verify the branch is ready (do this first) - -> **Committing and pushing is always the user's responsibility.** Per the Copilot instructions: *never run `git commit`, `git push`, or any destructive git command unless the user explicitly asks.* - -Before calling `mcp_github_create_pull_request`, verify that the expected changes are actually on the remote branch. Run: - -```ps -git log origin/<branch> --oneline -10 -``` - -and compare with what was implemented in this session. **If the branch on the remote does not contain the expected commits, stop and ask the user to push before continuing.** - -### Warning signs that the user may not have pushed yet - -- The most recent commit on the remote branch belongs to a completely different topic (e.g., work in this session was a bug fix, but the top commit on the remote is an old refactoring). -- The user just said something like "I created a new branch" — they may have created it locally without pushing. -- `git status` shows "Your branch is ahead of 'origin/…' by N commits". -- The remote branch does not exist at all (`git ls-remote --heads origin <branch>` returns nothing). - -When in doubt, ask: *"Have you pushed your changes to the remote branch?"* - ---- - -## 1 — Tool to use - -Always create pull requests via **`github-pull-request_create_pull_request`** (the VS Code GitHub Pull Requests extension tool). -Do **not** use `gh pr create` or any other CLI command. - -> `mcp_github_create_pull_request` (the GitHub MCP server tool) may fail with "Permission Denied" depending on the token scope. If it does, fall back to `github-pull-request_create_pull_request` and tell the user which tool was actually used. - -After creating the PR, add the PR type label via the `gh` CLI: -```ps -gh pr edit <number> --repo Mapsui/Mapsui --add-label "<label>" -``` - ---- - -## 2 — Title format - -``` -<prefix>: <short imperative summary> -``` - -- Use the **imperative mood** ("Add support for X", not "Added support for X"). -- Keep it concise — ideally under 72 characters. -- The prefix is lowercase; the rest of the title uses sentence case. - -### Prefix → PR type label mapping - -| Title prefix | PR type label | When to use | -|---|---|---| -| `fix:` | `PR type: � Fix` | A bug fix or patch for existing functionality | -| `feat:` | `PR type: 🚀 Feature` | A new capability or enhancement that users benefit from | -| `refactor:` | `PR type: ♻� Refactor` | Internal restructuring that doesn't change behavior or add features | -| `update:` | `PR type: 📦 Update` | Updates to third-party libraries, SDKs, NuGet packages | -| `perf:` | `PR type:⚡Performance` | A change that measurably improves speed, memory, or resource usage | -| `chore:` | `PR type: 🛠� Chore` | Internal changes that don't affect end users (code cleanup, build tweaks) | -| `ci:` | `PR type: ⚙� Infrastructure` | Changes to CI/CD pipelines, deployment scripts, config files, tooling | -| `sample:` | `PR type: 🧪 Sample` | Adding or updating a sample | -| `test:` | `PR type: ✅ Tests` | Adding or improving unit, integration, or regression tests without changing production code | -| `docs:` | `PR type: � Documentation` | Changes to user guides, README, API docs, etc. | - -> **Note on `feat:` vs `feature:`** — Use `feat:` (the conventional commits standard, dominant in .NET projects). Do not use `feature:`. - ---- - -## 3 — Required fields - -When calling `mcp_github_create_pull_request`, always provide: - -| Field | Guidance | -|---|---| -| `title` | `<prefix>: <summary>` as above | -| `body` | Describe *what* changed, *why*, and reference any related issues with `Fixes #NNN` or `Relates to #NNN` | -| `labels` | The matching "PR type" label from the table above | -| `base` | `main` (the default branch) | -| `draft` | `false` unless the work is explicitly incomplete | - ---- - -## 4 — Secondary labels - -In addition to the "PR type" label, you may apply additional labels as appropriate: - -| Label | When | -|---|---| -| `☢� Experimental` | PR touches any `Mapsui.Experimental.*` package | -| `bug �` | Companion to a bug report (issue side); not the same as `PR type: � Fix` | -| `documentation` | Supplementary docs label (distinct from PR type) | - -These are **optional** and supplementary — they do not replace the PR type label. - ---- - -## 5 — Release notes - -The `release.yml` file categorises PRs by their "PR type" label for automated GitHub release notes. Choosing the correct label ensures the PR appears in the right section of the changelog. - -| Release section | Label | -|---|---| -| � Fixes | `PR type: � Fix` | -| 🚀 Features | `PR type: 🚀 Feature` | -| ♻� Refactor | `PR type: ♻� Refactor` | -| 📦 Updates | `PR type: 📦 Update` | -| ⚡ Performance | `PR type:⚡Performance` | -| 🛠� Chore | `PR type: 🛠� Chore` | -| ⚙� Infrastructure | `PR type: ⚙� Infrastructure` | -| 🧪 Samples | `PR type: 🧪 Sample` | -| ✅ Tests | `PR type: ✅ Tests` | -| � Documentation | `PR type: � Documentation` | -| 💩 Other Changes | (catch-all for PRs with no recognised PR type label) | - ---- - -## 6 — Example - -A PR that fixes the tofu square rendering bug in vector tile street labels: - -``` -fix: DrawTextOnPath renders tofu square between words in street labels -``` - -- Labels: `PR type: � Fix`, `☢� Experimental` -- Body: describes root cause, references `Fixes #3346` -- Base branch: `main` diff --git a/.github/skills/done-checklist/SKILL.md b/.github/skills/done-checklist/SKILL.md deleted file mode 100644 index b53c9d8dd1..0000000000 --- a/.github/skills/done-checklist/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: done-checklist -description: Run the pre-PR completion checklist — build, unit tests, rendering regression tests, style, docs, architecture review, instructions improvement, and code review — tracked as a JSON file so nothing is skipped. Load this skill when you finish implementing any feature, bug fix, or refactoring before opening a pull request. ---- - -# Done Checklist - -Use this skill every time you finish implementing a feature or resolving an issue, before opening a pull request. State is stored in `.github/skills/done-checklist/state/<branch>.json` (gitignored). All chat sessions on the same branch share one file — one branch = one checklist. - -> **RULE: Never declare a feature done or open a PR until every task in the state file shows `"status": "completed"`.** - ---- - -## Step 1 — Decide which tasks to include - -| Task | Include when | -|---|---| -| `build` | Always | -| `unit-tests` | Always | -| `regression-tests` | Any change that touches rendering: styles, renderers, callouts, widgets, GridLayer, or anything in `Mapsui.Rendering.Skia` / `Mapsui.Experimental.Rendering.Skia`. When in doubt, include it. | -| `update-regression-images` | Include together with `regression-tests` **only** when rendering was intentionally changed (e.g. a new visual feature, not a bug fix). | -| `no-todos-left` | Always | -| `code-style` | Always | -| `copilot-guidelines` | Always | -| `upgrade-guide` | Always | -| `documentation` | Always | -| `architecture` | Always | -| `improve-instructions` | Always | -| `code-review`, `code-review-fixes-1`, `code-review-2`, `code-review-fixes-2`, `code-review-3`, `code-review-fixes-3` | Always (always last) | - ---- - -## Step 2 — Initialize or resume the state file - -Two files are involved, with clearly separate responsibilities: - -- **`tasks.json`** — task definitions (id, label, instructions, commands). This is the user-maintained source of truth for *what* the checklist does. The agent never modifies it. -- **`state/<branch>.json`** — progress tracking. This is agent-maintained and gitignored. It contains only the task IDs and their completion status. The user never touches it. - -The state file lives at `.github/skills/done-checklist/state/<branch>.json` where `<branch>` has `/` and `\` replaced with `_`. - -**At the start of every new chat session involving this skill, always read the state file first.** - -- **File does not exist**: create it at `state/<branch>.json` using `create_file`. Read `tasks.json` to get the task list, omit tasks where `"optional": true` that don't apply (see Step 1). The state file contains only ids and status — not the full task definitions: - ```json - { - "branch": "<branch>", - "createdAt": "<timestamp>", - "tasks": [ - { "id": "build", "status": "pending", "completedAt": null }, - { "id": "unit-tests", "status": "pending", "completedAt": null } - ] - } - ``` -- **File exists and this is a continuation** of interrupted work: read it and continue from the first `"pending"` task. -- **File exists but this is a fresh piece of work** on this branch: delete the old content and recreate as above. - ---- - -## Step 3 — Work through tasks in order - -Read the state file. Find the first task with `"status": "pending"`. Execute it. Mark it complete. Repeat until all tasks are `"status": "completed"`. - -### Command tasks (`"type": "command"`) - -1. Run the `command` in a terminal from the workspace root (`c:\code\github\Mapsui`). -2. Read `instruction` — it defines exactly what success means. -3. If it fails: **fix the problem, re-run, confirm it passes**, then mark complete. -4. Never mark a failing command complete. - -### Agent tasks (`"type": "agent"`) - -Perform the task yourself according to `instruction`: - -- **`regression-tests`** — Run the rendering regression tests. Results mean: `Passed` = matches reference; `Inconclusive` = no reference image yet, one was generated (review the generated image visually before marking complete); `Failed` = pixel difference exceeded threshold — compare the Generated vs Original images, fix the rendering cause, re-run until all results are Passed or Inconclusive. Never mark complete with any `Failed` results. -- **`update-regression-images`** — Only run this task after `regression-tests` passes/is-inconclusive and the rendering change was intentional. Run `.\Scripts\CopyGeneratedImagesOverOriginalImages.ps1`. Then use `git diff --name-only` and revert any image files that were NOT affected by your change (avoid binary noise in the git history). Report which images were updated before marking complete. -- **`no-todos-left`** — Search all changed files for `TODO`, `FIXME`, and `HACK` comments. Each one must be resolved now or tracked as a GitHub issue. Report any found before marking complete. -- **`code-style`** — Run `dotnet format Mapsui.slnx --verify-no-changes`. If violations are found, run `dotnet format Mapsui.slnx` to fix them, then re-run `dotnet build` to confirm nothing broke. Note: exit code 1 with only "Warnings were encountered while loading the workspace" in the output is a pre-existing issue (VexTile.Perf missing a SQLite dep) — not a style failure. Check the output for actual formatting changes made by running `git diff --name-only HEAD` after applying format. Report what was changed before marking complete. -- **`copilot-guidelines`** — Review all changed code against the guidelines in `.github/copilot-instructions.md`. Check specifically: compact style (var, expression-bodied, no unnecessary braces), comments explain *why* not *what*, no rendering in the draw/paint loop, lon/lat ordering (`SphericalMercator.FromLonLat(lon, lat)`), extension methods in correct `Extensions/` folder with correct class name (`{TypeItExtends}Extensions`), no new `IDisposable` on renderers for Skia resources (use `RenderService` instead), public API has XML doc comments. Fix any violation before marking complete. -- **`upgrade-guide`** — Check whether any public API was removed, renamed, or had its behavior changed. If yes, add an entry to `docs/general/markdown/v6.0-upgrade-guide.md` describing old API → new API with a one-sentence rationale. If no breaking changes, mark complete immediately. -- **`documentation`** — Review all changes and update any affected user-facing docs in `docs/general/markdown/`. Consider whether new documentation is needed (new concepts, new sample, behavior changes, new public API). If a new doc page was added, register it in `docs/general/mkdocs.yml`. Report what was done before marking complete. -- **`architecture`** — Review changes for architecture implications against the Mapsui hierarchy (`DataSource → Fetcher → Layer → Map → MapControl`). Check: does anything push `IDisposable`, `async/await`, or exception-throwing code deeper into core than it should be? Does anything render inside a draw loop? Is anything coupled that should be independent? If NO refactoring needed: mark complete immediately. If YES: discuss with the user and get explicit agreement before proceeding. -- **`improve-instructions`** — Reflect on this session: was there anything that took several attempts, required searching in multiple places, or was unexpectedly hard to figure out? If yes, improve the relevant instructions or skill so the next session goes faster. Good candidates: a convention that wasn't documented, a command that wasn't obvious, a pattern that needed several searches to locate, a gotcha in the codebase. Update `.github/copilot-instructions.md` for general conventions, a specific `.github/instructions/*.instructions.md` for subsystem-specific knowledge, or a skill file for workflow steps. Report what you added or changed, or explain why nothing needed updating. Mark complete only after making the improvement (or genuinely concluding nothing was friction). -- **`code-review`** — Review ALL changes for correctness, edge cases, dead code, security, naming, and conventions. List ALL findings. Do NOT fix yet. If nothing found, mark `code-review-fixes-1`, `code-review-2`, `code-review-fixes-2`, `code-review-3`, and `code-review-fixes-3` all complete immediately. -- **`code-review-fixes-1`** — Apply every fix from `code-review`. Re-run build and unit tests after. If nothing was found in `code-review`, mark this and all remaining review tasks complete immediately. -- **`code-review-2`** — Second pass focusing on anything changed in `code-review-fixes-1`. List findings. Do NOT fix yet. If nothing found, mark `code-review-fixes-2`, `code-review-3`, `code-review-fixes-3` complete immediately. -- **`code-review-fixes-2`** — Apply every fix from `code-review-2`. Re-run build and unit tests. If nothing found, mark the remaining two tasks complete immediately. -- **`code-review-3`** — Third and final pass focusing on anything changed in `code-review-fixes-2`. List findings. Do NOT fix yet. If nothing found, mark `code-review-fixes-3` complete immediately. -- **`code-review-fixes-3`** — Apply every fix from `code-review-3`. Re-run build and unit tests. If nothing found, mark complete immediately. - -### Marking a task complete - -Use `replace_string_in_file` on the state file. The task `"id"` is unique — include it in the match for context. Change `"status": "pending"` to `"status": "completed"` and set `"completedAt"` to the current timestamp. Example: - -```json - "id": "build", - "status": "pending", - "completedAt": null -``` - -→ - -```json - "id": "build", - "status": "completed", - "completedAt": "2026-04-25T10:00:00.000Z" -``` - ---- - -## Rules - -- **Never mark a failing task complete.** Fix → re-run → pass → complete. -- **Never skip a task.** -- **Zero tolerance for red tests.** Every test must be green. -- **Architecture task:** if refactoring is needed, discuss with the user before proceeding. Never silently mark it complete. -- **Rendering regression tests:** `Inconclusive` is acceptable (new sample, no reference yet — just review the generated image). `Failed` is never acceptable. diff --git a/.github/skills/done-checklist/state/.gitignore b/.github/skills/done-checklist/state/.gitignore deleted file mode 100644 index b16e47f9fb..0000000000 --- a/.github/skills/done-checklist/state/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Ignore all state files — these are local per-developer, per-branch tracking files. -*.json diff --git a/.github/skills/done-checklist/tasks.json b/.github/skills/done-checklist/tasks.json deleted file mode 100644 index e4ce769860..0000000000 --- a/.github/skills/done-checklist/tasks.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "tasks": [ - { - "id": "build", - "label-for-human": "Build solution", - "type": "command", - "command": "dotnet build", - "instruction": "Run the full solution build. Read ALL output. Fix every compilation error and warning before marking complete.", - "optional": false - }, - { - "id": "unit-tests", - "label-for-human": "Run unit tests", - "type": "command", - "command": "dotnet test --filter \"Category!=Regression\"", - "instruction": "Run all unit tests (excluding rendering regression tests). Every test must be green — zero failures. Fix any failure before marking complete.", - "optional": false - }, - { - "id": "regression-tests", - "label-for-human": "Run rendering regression tests", - "type": "agent", - "command": "dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter \"TestSampleAsync\"", - "instruction": "Run all rendering regression tests. Results: Passed = matches reference image (good); Inconclusive = no reference yet, one was generated — review the generated image visually before marking complete; Failed = pixel difference exceeded threshold — compare Generated vs Original images (both under Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/), fix the rendering cause, re-run until no Failed results remain. Never mark complete with any Failed results.", - "optional": true - }, - { - "id": "update-regression-images", - "label-for-human": "Update regression reference images", - "type": "agent", - "command": null, - "instruction": "Run .\\Scripts\\CopyGeneratedImagesOverOriginalImages.ps1 to promote generated images to reference images. Then run 'git diff --name-only' and revert any image files that were NOT affected by your intentional rendering change — avoid committing unrelated binary diffs. Report which images were updated before marking complete.", - "optional": true - }, - { - "id": "no-todos-left", - "label-for-human": "No untracked TODOs", - "type": "agent", - "command": null, - "instruction": "Search all changed files for TODO, FIXME, and HACK comments introduced by this change. Each one must be resolved now or tracked as a GitHub issue. Do not leave untracked debt in the codebase. Report any found before marking complete.", - "optional": false - }, - { - "id": "code-style", - "label-for-human": "Code style check", - "type": "agent", - "command": null, - "instruction": "Run 'dotnet format --check'. If violations are found, run 'dotnet format' to fix them automatically, then re-run 'dotnet build' to confirm nothing broke. Report what was changed before marking complete. If no violations, mark complete immediately.", - "optional": false - }, - { - "id": "copilot-guidelines", - "label-for-human": "Mapsui guidelines review", - "type": "agent", - "command": null, - "instruction": "Review all changed code against .github/copilot-instructions.md. Check: (1) compact style — var everywhere, expression-bodied members, optional braces for single-statement blocks; (2) comments explain *why* not *what*; (3) no rendering (SKPath creation etc.) inside the draw/paint loop — separate rendering from drawing; (4) lon/lat ordering — always SphericalMercator.FromLonLat(lon, lat); (5) extension methods in Extensions/ folder, class named {TypeItExtends}Extensions; (6) no new IDisposable on renderer classes for Skia resources — store in RenderService instead; (7) new public members have XML doc comments. Fix any violation before marking complete.", - "optional": false - }, - { - "id": "upgrade-guide", - "label-for-human": "Upgrade guide — breaking changes", - "type": "agent", - "command": null, - "instruction": "Check whether any public API was removed, renamed, or had its observable behavior changed. If yes, add an entry to docs/general/markdown/v6.0-upgrade-guide.md: old API → new API with a one-sentence rationale. Keep entries concise. If no breaking changes, mark complete immediately.", - "optional": false - }, - { - "id": "documentation", - "label-for-human": "Update documentation", - "type": "agent", - "command": null, - "instruction": "Review all changes and update any affected user-facing docs in docs/general/markdown/. Consider whether new documentation is needed (new concepts, new public API, behavior changes). If a new doc page was added, register it in docs/general/mkdocs.yml. Report what was done before marking complete.", - "optional": false - }, - { - "id": "architecture", - "label-for-human": "Architecture review", - "type": "agent", - "command": null, - "instruction": "Review changes for architecture implications against the Mapsui hierarchy (DataSource → Fetcher → Layer → Map → MapControl). Check: does anything push IDisposable, async/await, or exception-throwing code deeper into core than it should be? Does anything render inside a draw loop? Is anything coupled that should be independent? If NO refactoring needed: mark complete immediately. If YES: discuss with the user and get explicit agreement before proceeding. Never silently mark complete if refactoring is needed.", - "optional": false - }, - { - "id": "improve-instructions", - "label-for-human": "Improve instructions from friction", - "type": "agent", - "command": null, - "instruction": "Reflect on this session: was there anything that took several attempts, required searching in multiple places, or was unexpectedly hard to figure out? If yes, improve the relevant instructions or skill so the next session goes faster. Good candidates: a convention that wasn't documented, a command that wasn't obvious, a pattern that needed several searches to locate, a gotcha in the codebase. Update .github/copilot-instructions.md for general conventions, a specific .github/instructions/*.instructions.md for subsystem-specific knowledge, or a skill file for workflow steps. Report what you added or changed, or explain why nothing needed updating. Mark complete only after making the improvement (or genuinely concluding nothing was friction).", - "optional": false - }, - { - "id": "code-review", - "label-for-human": "Thorough code review — pass 1", - "type": "agent", - "command": null, - "instruction": "Perform a thorough review of ALL changes made for this feature: correctness, edge cases, dead code, security, naming consistency, adherence to project conventions (copilot-instructions.md). List ALL findings clearly. Do NOT fix anything yet — fixes happen in code-review-fixes-1. If you found nothing at all, mark code-review-fixes-1, code-review-2, code-review-fixes-2, code-review-3, and code-review-fixes-3 as completed immediately.", - "optional": false - }, - { - "id": "code-review-fixes-1", - "label-for-human": "Apply code review fixes — pass 1", - "type": "agent", - "command": null, - "instruction": "Apply every fix identified in code-review. Re-run build and unit tests after applying fixes. If nothing was found in code-review, mark this task and all remaining review tasks (code-review-2, code-review-fixes-2, code-review-3, code-review-fixes-3) complete immediately.", - "optional": false - }, - { - "id": "code-review-2", - "label-for-human": "Thorough code review — pass 2", - "type": "agent", - "command": null, - "instruction": "Second pass review focusing on anything changed in code-review-fixes-1. List ALL findings. Do NOT fix yet. If nothing found, mark code-review-fixes-2, code-review-3, and code-review-fixes-3 complete immediately.", - "optional": false - }, - { - "id": "code-review-fixes-2", - "label-for-human": "Apply code review fixes — pass 2", - "type": "agent", - "command": null, - "instruction": "Apply every fix identified in code-review-2. Re-run build and unit tests after applying fixes. If nothing was found in code-review-2, mark this task and the remaining two (code-review-3, code-review-fixes-3) complete immediately.", - "optional": false - }, - { - "id": "code-review-3", - "label-for-human": "Thorough code review — pass 3", - "type": "agent", - "command": null, - "instruction": "Third and final pass review focusing on anything changed in code-review-fixes-2. List ALL findings. Do NOT fix yet. If nothing found, mark code-review-fixes-3 complete immediately.", - "optional": false - }, - { - "id": "code-review-fixes-3", - "label-for-human": "Apply code review fixes — pass 3", - "type": "agent", - "command": null, - "instruction": "Apply every fix identified in code-review-3. Re-run build and unit tests after applying fixes. If nothing was found in code-review-3, mark complete immediately.", - "optional": false - } - ] -} diff --git a/.github/workflows/dotnet-docs.yml b/.github/workflows/dotnet-docs.yml index f8564c15d9..b52c968619 100644 --- a/.github/workflows/dotnet-docs.yml +++ b/.github/workflows/dotnet-docs.yml @@ -2,25 +2,34 @@ name: Build Website on: push: - branches: + branches: - main paths: - 'docs/**' + - 'Scripts/BuildDocumentationWebSite.ps1' + - 'Scripts/BuildSamplesWebsite.ps1' + - 'Scripts/AddFrozenV4Website.ps1' + - '.github/pages-v4-snapshot.*' + - '.github/workflows/dotnet-docs.yml' pull_request: branches: - main paths: - 'docs/**' - workflow_dispatch: {} - + - 'Scripts/BuildDocumentationWebSite.ps1' + - 'Scripts/BuildSamplesWebsite.ps1' + - 'Scripts/AddFrozenV4Website.ps1' + - '.github/pages-v4-snapshot.*' + - '.github/workflows/dotnet-docs.yml' permissions: - contents: write - # Grant GITHUB_TOKEN the permissions required to make a Pages deployment - pages: write # to deploy to Pages - id-token: write # to verify the deployment originates from an appropriate source + contents: read + +concurrency: + group: mapsui-pages-${{ github.ref }} + cancel-in-progress: true jobs: - build-and-publish-website: + build-website: runs-on: windows-latest env: PYTHONUTF8: 1 @@ -48,11 +57,11 @@ jobs: fetch-depth: 0 filter: tree:0 - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x - name: install workloads run: dotnet workload install maui macos android ios maccatalyst wasm-tools wasm-tools-net8 - name: Run DocFx @@ -61,30 +70,67 @@ jobs: # build blazor samples to website/samples/ - name: Build Blazor Website Sample shell: pwsh - run: ./Scripts/BuildSamplesWebsite.ps1 - - name: Checkout gh-pages branch to delete v5 folder - uses: actions/checkout@v4 - with: - ref: gh-pages - path: gh-pages - - name: Delete v5 folder - shell: bash + run: ./Scripts/BuildSamplesWebsite.ps1 + - name: Download frozen v4 website + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} run: | - rm -rf gh-pages/v5 - - name: Push gh-pages branch with v5 folder deleted + $manifest = Get-Content './.github/pages-v4-snapshot.json' | ConvertFrom-Json + $archivePath = Join-Path $env:RUNNER_TEMP $manifest.assetName + gh release download $manifest.releaseTag ` + --repo $env:GITHUB_REPOSITORY ` + --pattern $manifest.assetName ` + --dir $env:RUNNER_TEMP + if ($LASTEXITCODE -ne 0) { + throw 'Could not download the frozen v4 website.' + } + ./Scripts/AddFrozenV4Website.ps1 ` + -ArchivePath $archivePath ` + -WebsitePath ./website ` + -ExpectedSha256 $manifest.sha256 ` + -ExpectedFileCount $manifest.fileCount + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./website + + verify-deployment-source: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + should_deploy: ${{ steps.merged-pull-request.outputs.should_deploy }} + steps: + - name: Require a pull request merged into main + id: merged-pull-request + env: + GH_TOKEN: ${{ github.token }} shell: bash run: | - cd gh-pages - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Clean v5 folder before redeploy" || echo "Nothing to commit" - git push - - name: Upload artifact - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./website - destination_dir: . - keep_files: true + merged_pull_requests="$( + gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ + --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | length' + )" + if [[ "$merged_pull_requests" -gt 0 ]]; then + echo "should_deploy=true" >> "$GITHUB_OUTPUT" + else + echo "should_deploy=false" >> "$GITHUB_OUTPUT" + echo "Skipping deployment because $GITHUB_SHA was not merged through a pull request into main." + fi + deploy-website: + if: needs.verify-deployment-source.outputs.should_deploy == 'true' + needs: + - build-website + - verify-deployment-source + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/dotnet-release-nugets.yml b/.github/workflows/dotnet-release-nugets.yml index dfcd731c24..a62f88c74b 100644 --- a/.github/workflows/dotnet-release-nugets.yml +++ b/.github/workflows/dotnet-release-nugets.yml @@ -30,11 +30,11 @@ jobs: uses: NuGet/setup-nuget@v2 - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x # The version tag needs to be set before the release. If not if will fail on nuget publish. - name: Set VERSION_OF_RELEASE to last tag diff --git a/.github/workflows/dotnet-validate-nugets.yml b/.github/workflows/dotnet-validate-nugets.yml index ce83612831..22f6505578 100644 --- a/.github/workflows/dotnet-validate-nugets.yml +++ b/.github/workflows/dotnet-validate-nugets.yml @@ -22,11 +22,11 @@ jobs: ${{ runner.os }}-nuget22- # .Net 9 update - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x # Java Sdk install 11 - name: Install Open JDK 11 uses: actions/setup-java@v4 @@ -46,15 +46,26 @@ jobs: - name: Restore dependencies (dotnet) run: dotnet restore Mapsui.slnx # Release Build + # Forks have no release tags, so `git describe --tags` yields an empty version and the pack step + # fails with MSB4044. Fall back to a placeholder so this is runnable on a fork; upstream, where + # the tags exist, it resolves to the real version exactly as before. This job runs on Windows, + # so the step is PowerShell rather than the bash form used in dotnet.yml. + - name: Determine package version + shell: pwsh + run: | + $version = git describe --tags 2>$null + if ([string]::IsNullOrWhiteSpace($version)) { $version = '0.0.1-fork' } + "PACKAGE_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append + $LASTEXITCODE = 0 - name: Build nuget packages - run: dotnet pack Mapsui.slnx --configuration Release /p:Version=$(git describe --tags) -o Artifacts + run: dotnet pack Mapsui.slnx --configuration Release /p:Version=$env:PACKAGE_VERSION -o Artifacts - name: Cleanup run: git clean -fx -d -e Artifacts # Change Project References to nuget package references in samples - name: nuget ProjectReferences to PackageReferences shell: pwsh run: | - ./Scripts/SamplesMapsuiNugetReferences.ps1 $(git describe --tags) + ./Scripts/SamplesMapsuiNugetReferences.ps1 $env:PACKAGE_VERSION - name: Restore dependencies (dotnet) run: dotnet restore Mapsui.slnx # Samples Build @@ -62,9 +73,11 @@ jobs: run: dotnet build --no-restore --configuration Debug Mapsui.WithoutMaui.slnf - name: Mapsui Samples MAUI run: dotnet build --no-restore --configuration Debug Mapsui.Maui.slnf + - name: Test packaged MAUI consumer with SkiaSharp 4 + run: dotnet test Tests/Mapsui.UI.Maui.Tests/Mapsui.UI.Maui.Tests.csproj --configuration Debug /p:MapsuiPackageVersion=$env:PACKAGE_VERSION # Publishing - name: Upload packages uses: actions/upload-artifact@v4 with: name: nupkg.validation - path: Artifacts/*.nupkg \ No newline at end of file + path: Artifacts/*.nupkg diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 671c193e43..afcb2176b9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -59,11 +59,11 @@ jobs: with: nuget-version: latest - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x # Open JDK - name: Install Open JDK 17 uses: actions/setup-java@v4 @@ -75,6 +75,7 @@ jobs: uses: android-actions/setup-android@v3 with: cmdline-tools-version: '8512546' + packages: 'platform-tools' - name: Install workloads android wasm-tools wasm-tools-net8 maui-android run: dotnet workload install android wasm-tools wasm-tools-net8 maui-android - name: Install Uno Check @@ -93,8 +94,13 @@ jobs: - name: Run Mapsui.Rendering.Skia.Tests run: dotnet test Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Mapsui.Rendering.Skia.Tests.dll --blame-hang-timeout:60s # Release Build + # Forks have no release tags, so `git describe --tags` yields an empty version and the pack + # steps fail with MSB4044. Fall back to a placeholder so CI is green on forks; upstream, where + # the tags exist, this resolves to the real version exactly as before. + - name: Determine package version + run: echo "PACKAGE_VERSION=$(git describe --tags 2>/dev/null || echo 0.0.1-fork)" >> $GITHUB_ENV - name: Build Linux packages - run: dotnet pack /p:RestorePackages=true /p:Configuration=Release /p:Version=$(git describe --tags) Mapsui.Linux.slnf --output Artifacts + run: dotnet pack /p:RestorePackages=true /p:Configuration=Release /p:Version=$PACKAGE_VERSION Mapsui.Linux.slnf --output Artifacts - name: Upload packages uses: actions/upload-artifact@v4 with: @@ -127,11 +133,11 @@ jobs: xcode-version: '26.5' # .Net 9 update - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x - name: Set MSBuildEnableWorkloadResolver run: echo "MSBuildEnableWorkloadResolver=true" >> $GITHUB_ENV - name: install workloads maui macos android ios maccatalyst wasm-tools wasm-tools-net8 @@ -161,26 +167,31 @@ jobs: - name: Run Mapsui.Rendering.Skia.Tests run: dotnet test Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Mapsui.Rendering.Skia.Tests.dll --blame-hang-timeout:60s # Release Build + # Forks have no release tags, so `git describe --tags` yields an empty version and the pack + # steps fail with MSB4044. Fall back to a placeholder so CI is green on forks; upstream, where + # the tags exist, this resolves to the real version exactly as before. + - name: Determine package version + run: echo "PACKAGE_VERSION=$(git describe --tags 2>/dev/null || echo 0.0.1-fork)" >> $GITHUB_ENV - name: Build Mapsui - run: dotnet pack --no-restore --configuration Release Mapsui/Mapsui.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui/Mapsui.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.Rendering.Skia - run: dotnet pack --no-restore --configuration Release Mapsui.Rendering.Skia/Mapsui.Rendering.Skia.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.Rendering.Skia/Mapsui.Rendering.Skia.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.Tiling - run: dotnet pack --no-restore --configuration Release Mapsui.Tiling/Mapsui.Tiling.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.Tiling/Mapsui.Tiling.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.Nts - run: dotnet pack --no-restore --configuration Release Mapsui.Nts/Mapsui.Nts.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.Nts/Mapsui.Nts.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.ArcGIS - run: dotnet pack --no-restore --configuration Release Mapsui.ArcGIS/Mapsui.ArcGIS.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.ArcGIS/Mapsui.ArcGIS.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.Extensions - run: dotnet pack --no-restore --configuration Release Mapsui.Extensions/Mapsui.Extensions.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.Extensions/Mapsui.Extensions.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.UI.Android - run: dotnet pack --no-restore --configuration Release Mapsui.UI.Android/Mapsui.UI.Android.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.UI.Android/Mapsui.UI.Android.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.UI.iOS - run: dotnet pack --no-restore --configuration Release Mapsui.UI.iOS/Mapsui.UI.iOS.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.UI.iOS/Mapsui.UI.iOS.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.UI.Maui - run: dotnet pack --no-restore --configuration Release Mapsui.UI.Maui/Mapsui.UI.Maui.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.UI.Maui/Mapsui.UI.Maui.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Build Mapsui.UI.Uno.WinUI - run: dotnet pack --no-restore --configuration Release Mapsui.UI.Uno.WinUI/Mapsui.UI.Uno.WinUI.csproj -o Artifacts -p:PackageVersion=$(git describe --tags) + run: dotnet pack --no-restore --configuration Release Mapsui.UI.Uno.WinUI/Mapsui.UI.Uno.WinUI.csproj -o Artifacts -p:PackageVersion=$PACKAGE_VERSION - name: Upload packages uses: actions/upload-artifact@v4 with: @@ -205,11 +216,11 @@ jobs: ${{ runner.os }}-nuget22- # .Net 8 update - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 9.0.305 - 8.0.407 + 8.0.x # Java Sdk install 17 - name: Install Open JDK 17 uses: actions/setup-java@v4 diff --git a/.gitignore b/.gitignore index 01af05c0a5..4a67cc892b 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,5 @@ AppPackages/ /Benchmarks/Mapsui.Rendering.Benchmarks/BenchmarkDotNet.Artifacts/results /Benchmarks/Mapsui.Rendering.Benchmarks/BenchmarkDotNet.Artifacts/*.log /.playwright-mcp +/.codex/ config.local.json - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..74cc147534 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Mapsui agent guide + +`AGENTS.md` is the repository's vendor-neutral agent entrypoint. Treat the material under `.agents/` as the canonical source for agent guidance. Do not create or maintain a second, vendor-specific set of project rules. + +## Start here + +1. Read `.agents/instructions/repository.md` before analysing or changing the project. +2. Before making code changes, assess validation using the policy below. Load the `checklist` skill only when the assessment selects a partial or full checklist. +3. For changes in either Skia renderer, also read `.agents/instructions/richtextkit.instructions.md`. + +## Validation assessment + +For every code change, choose one validation level before editing and reassess it after editing: + +- **Relevant tests only:** use when the change is small and isolated and one obvious test project, class, or method covers it. Do not load the checklist skill. +- **Partial checklist:** use when several targeted builds, tests, or reviews are needed. Load `.agents/skills/checklist/SKILL.md` and select the relevant checks. +- **Full checklist:** use for broad, high-risk, cross-platform, dependency, build, release, or pre-PR changes, or when the user asks for exhaustive validation. Load `.agents/skills/checklist/SKILL.md` and run every required full check, including Android builds. + +Documentation-only and agent-tooling-only changes normally need relevant structural checks rather than the checklist skill. State the chosen level, why it is sufficient, and the checks actually run. + +## Working agreement + +- Do not edit files, create or switch branches, commit, push, or create a pull request unless the user explicitly requests that specific action. A request to create a pull request authorizes only pull-request creation; it does not authorize any of the preceding Git actions. +- Keep changes focused, respect `.editorconfig`, and run the validation that is appropriate to the change. +- Treat `docs/` as the source of truth for product and architecture details; agent material should link to it rather than duplicate it. + +## Compatibility entrypoints + +`CLAUDE.md`, `GEMINI.md`, and `.github/copilot-instructions.md` are small compatibility entrypoints. They deliberately point here so each tool follows the same guidance. Codex discovers this file directly. + +See `.agents/README.md` for the layout and ownership rules. diff --git a/Benchmarks/Mapsui.Rendering.Benchmarks/Program.cs b/Benchmarks/Mapsui.Rendering.Benchmarks/Program.cs index 169ff45203..32ff067546 100644 --- a/Benchmarks/Mapsui.Rendering.Benchmarks/Program.cs +++ b/Benchmarks/Mapsui.Rendering.Benchmarks/Program.cs @@ -3,8 +3,8 @@ namespace Mapsui.Rendering.Benchmarks; public class Program { - public static void Main() + public static void Main(string[] args) { - _ = BenchmarkRunner.Run(typeof(Program).Assembly); + _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); } } diff --git a/Benchmarks/Mapsui.Rendering.Benchmarks/ScaleBarFontPerformance.cs b/Benchmarks/Mapsui.Rendering.Benchmarks/ScaleBarFontPerformance.cs new file mode 100644 index 0000000000..2956a1da53 --- /dev/null +++ b/Benchmarks/Mapsui.Rendering.Benchmarks/ScaleBarFontPerformance.cs @@ -0,0 +1,50 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using Mapsui.Layers; +using Mapsui.Rendering.Skia; +using Mapsui.Widgets; +using Mapsui.Widgets.ScaleBar; +using SkiaSharp; + +namespace Mapsui.Rendering.Benchmarks; + +[SimpleJob(RunStrategy.Throughput)] +[MemoryDiagnoser] +[MinColumn, MaxColumn, MeanColumn, MedianColumn] +public class ScaleBarFontPerformance : IDisposable +{ + private readonly Map _map = new() { CRS = "EPSG:3857" }; + private readonly MapRenderer _renderer = new(); + private readonly RenderService _renderServiceWithoutCache = new(); + private readonly SKSurface _surface = SKSurface.Create(new SKImageInfo(800, 600)); + private readonly IWidget[] _widgets; + + public ScaleBarFontPerformance() + { + _map.Navigator.SetSize(800, 600); + _map.Navigator.CenterOnAndZoomTo(new MPoint(0, 0), 1000); + _widgets = [new ScaleBarWidget(_map)]; + _renderServiceWithoutCache.VectorCache.Enabled = false; + } + + [Benchmark(Baseline = true)] + public void RenderWithoutFontCache() + { + _renderer.Render(_surface.Canvas, _map.Navigator.Viewport, Array.Empty<ILayer>(), _widgets, + _renderServiceWithoutCache); + } + + [Benchmark] + public void RenderWithFontCache() + { + _renderer.Render(_surface.Canvas, _map.Navigator.Viewport, Array.Empty<ILayer>(), _widgets, + _map.RenderService); + } + + public virtual void Dispose() + { + _surface.Dispose(); + _renderServiceWithoutCache.Dispose(); + _map.Dispose(); + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 55873dbd46..e248ce88cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,106 +1,7 @@ -# CLAUDE.md +# Mapsui agent instructions -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This is a compatibility entrypoint for Claude Code. -## Common Commands +@AGENTS.md -### Build -```bash -# Restore packages and build the entire solution in Debug configuration -# (the Release configuration is identical except for the --configuration flag) -# -# Usage: -# dotnet restore && dotnet build Mapsui.slnx --configuration Debug -# dotnet restore && dotnet build Mapsui.slnx --configuration Release -``` - -### Lint / Format -The repository enforces a compact style via `dotnet format` and `.editorconfig`. -```bash -# Check for style violations (exits non‑zero if any exist) -# -# dotnet format --check -# -# Apply automatic fixes (may modify the repository – run after a successful test run) -# -# dotnet format -``` - -### Run Tests -All unit and regression tests live under `Tests/`. -```bash -# Run the entire test suite (all projects in the solution) -# -# dotnet test -``` - -#### Run a single test project -```bash -# Example – run only the rendering regression tests -# -# dotnet test Tests/Mapsui.Rendering.Skia.Tests -``` - -#### Run a single test method or class -```bash -# Use the fully‑qualified name (including namespace) -# Example: run the CalloutSample test class in the rendering regression suite -# -# dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~CalloutSample" -# -# To target a specific method: -# dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~CalloutSample.TestMethod" -``` - -#### Rendering regression tests -These tests render every sample and compare the output pixel‑by‑pixel against stored reference images. - -- **Run a single sample** (fastest for targeted changes): - ```bash - dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "FullyQualifiedName~CalloutSample" - ``` -- **Run all regression tests**: - ```bash - dotnet test Tests/Mapsui.Rendering.Skia.Tests --filter "TestSampleAsync" - ``` -- **Interpret results**: - - `Passed` – output matches the reference image. - - `Inconclusive` – no reference image yet; the test generated one in `GeneratedRegression/`. - - `Failed` – pixel difference exceeded the threshold. Compare - - Generated: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/GeneratedRegression/` - - Reference: `Tests/Mapsui.Rendering.Skia.Tests/bin/Debug/net9.0/Resources/Images/OriginalRegression/` -- **Update reference images** after intentional rendering changes: - ```bash - .\Scripts\CopyGeneratedImagesOverOriginalImages.ps1 - ``` - (Revert any unrelated binary files afterward.) - -## High‑Level Architecture - -- **Core library (`Mapsui`)** – Implements map rendering, layers, providers, styles, utilities, and the `Map` abstraction. Targets .NET 9 and is platform‑agnostic. -- **UI wrappers** – Separate projects for each UI framework (e.g., `Mapsui.UI.Maui`, `Mapsui.UI.Wpf`, `Mapsui.UI.Blazor`). Each exposes a `MapControl` that hosts a `Map` instance. -- **Sample projects** – Under `Samples`. They reference the appropriate UI wrapper and demonstrate usage. They are built only for demonstration and are not required for library consumption. -- **Source generator (`Mapsui.Sample.SourceGenerator`)** – Scans all classes that implement `ISample`, `ISampleBase`, `ISampleTest`, or `IMapViewSample` at build time and generates a `Samples.Register()` method. Adding a new sample class that implements one of these interfaces is sufficient – no manual registration is needed. -- **Tests** – Unit tests in `Tests/`. Rendering regression tests are in `Tests/Mapsui.Rendering.Skia.Tests`. -- **CI** – GitHub Actions defined in `.github/workflows/dotnet.yml` perform lint, build, and test steps for each push/PR. - -## Build Configuration - -- Solution file: `Mapsui.slnx` (used by Visual Studio and the `dotnet` CLI). -- Projects target .NET 9 (`net9.0`). -- Packages are published via `dotnet pack` and `dotnet nuget push` (not part of the daily dev workflow). - -## Style and Quality - -- `.editorconfig` and `dotnet format` enforce a compact style (use `var`, optional braces, expression‑bodied members, etc.). -- Copilot instructions are captured in `.github/copilot‑instructions.md` – follow those guidelines when writing or refactoring code. -- Tests should be deterministic, fast, and avoid external dependencies unless mocked. - -## Documentation - -- Docs are under `docs/` and built with DocFX. -- Public APIs are documented with XML comments; keep them up‑to‑date when changing surface types. - ---- - -This CLAUDE.md is generated automatically; future instances of Claude Code should refer to it for consistent workflow guidance. +Claude Code does not currently discover repository skills from `.agents/skills` automatically. When `AGENTS.md` selects a skill, read its `SKILL.md` from that directory explicitly. diff --git a/Directory.Build.props b/Directory.Build.props index 80c68d5b97..abfec6efd7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ <SymbolPackageFormat>snupkg</SymbolPackageFormat> <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild> <PackageTags>map maps mapping geo gis osm</PackageTags> - <PackageIcon>icon.png</PackageIcon> + <PackageIcon>logo-128.png</PackageIcon> <IsPackable>false</IsPackable> <!--Default to not packable and override in projects that do need to be packed.--> <!--Disable Run Api Compat Task causes Build failure on github actions with visual studio 2022 17.5 and .net Compilers Toolset 4.6--> <RunApiCompat>false</RunApiCompat> @@ -24,19 +24,26 @@ <IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible> <GenerateDocumentationFile>True</GenerateDocumentationFile> <NoWarn>$(NoWarn),1573,1591,1712</NoWarn> - <MauiVersion>9.0.50</MauiVersion> + <MauiVersion>9.0.120</MauiVersion> <Maui8Version>8.0.100</Maui8Version> - <Avalonia12Version>12.0.2</Avalonia12Version> - <!-- Avalonia 12.0.2 requires SkiaSharp 3.119.4-preview.1.1 and HarfBuzzSharp 8.3.1.3 --> - <Avalonia12SkiaSharpVersion>3.119.4-preview.1.1</Avalonia12SkiaSharpVersion> - <Avalonia12HarfBuzzSharpVersion>8.3.1.3</Avalonia12HarfBuzzSharpVersion> + <!-- Last SkiaSharp that still ships net8.0 assets; 3.119.4 and later are net9.0+ only. --> + <Maui8SkiaSharpVersion>3.119.2</Maui8SkiaSharpVersion> + <!-- The MAUI sample and tests consume v4 while the shared renderer stays compatible with Avalonia's v3 dependency. --> + <SkiaSharp4CompatibilityVersion>4.151.2</SkiaSharp4CompatibilityVersion> + <HarfBuzzSharp4CompatibilityVersion>14.2.1.102</HarfBuzzSharp4CompatibilityVersion> + <Avalonia12Version>12.1.2</Avalonia12Version> + <!-- Avalonia 12.1.2 requires SkiaSharp 3.119.4 and HarfBuzzSharp 8.3.1.3, which match the + central versions in Directory.Packages.props. The overrides are kept so a future bump of the + central SkiaSharp version does not silently drag Avalonia 12 along with it. --> + <Avalonia12SkiaSharpVersion>3.119.4</Avalonia12SkiaSharpVersion> + <Avalonia12HarfBuzzSharpVersion>8.3.1.5</Avalonia12HarfBuzzSharpVersion> <!-- In Avalonia 12, Avalonia.ReactiveUI was replaced by ReactiveUI.Avalonia --> - <Avalonia12ReactiveUIVersion>12.0.1</Avalonia12ReactiveUIVersion> + <Avalonia12ReactiveUIVersion>12.1.2</Avalonia12ReactiveUIVersion> </PropertyGroup> <!--Package Icon Include --> <ItemGroup> - <None Include="..\logo\png\icon.png" Pack="true" PackagePath="\"/> + <None Include="..\assets\generated-no-margin\logo\logo-128.png" Pack="true" PackagePath="\"/> <None Include="..\LICENSE" Pack="true" PackagePath="\" /> </ItemGroup> diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b2b864654..7197de10b8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,17 +6,17 @@ <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> </PropertyGroup> <ItemGroup> - <PackageVersion Include="Avalonia" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Skia" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Desktop" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Browser" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Android" Version="11.3.1" /> - <PackageVersion Include="Avalonia.iOS" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Diagnostics" Version="11.3.1" /> - <PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.1" /> - <PackageVersion Include="ReactiveUI.Avalonia" Version="12.0.1" /> - <PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.1" /> - <PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.1" /> + <PackageVersion Include="Avalonia" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Skia" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Desktop" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Browser" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Android" Version="11.3.9" /> + <PackageVersion Include="Avalonia.iOS" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Diagnostics" Version="11.3.9" /> + <PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.9" /> + <PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.2" /> + <PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.9" /> + <PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.9" /> <PackageVersion Include="BenchmarkDotNet" Version="[0.13.9,1.0.0)" /> <PackageVersion Include="BitMiracle.LibTiff.NET" Version="2.4.660" /> <PackageVersion Include="BruTile" Version="[6.0.0,7.0)" /> @@ -33,8 +33,8 @@ <PackageVersion Include="Eto.Platform.XamMac2" Version="[2.8.0,3.0.0)" /> <PackageVersion Include="Eto.Platform.Gtk" Version="[2.8.0,3.0.0)" /> <PackageVersion Include="Eto.SkiaDraw" Version="[0.2.0,0.3.0)" /> - <PackageVersion Include="HarfBuzzSharp" Version="8.3.1.3" /> - <PackageVersion Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.3" /> + <PackageVersion Include="HarfBuzzSharp" Version="8.3.1.5" /> + <PackageVersion Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.5" /> <PackageVersion Include="IDisposableAnalyzers" Version="[4.0.8,5.0.0)" /> <PackageVersion Include="Mapsui.Android" Version="5.0.0-beta.8" /> <PackageVersion Include="Mapsui.ArcGIS" Version="5.0.0-beta.8" /> @@ -65,20 +65,19 @@ <PackageVersion Include="NetTopologySuite.IO.VectorTiles.Mapbox" Version="1.1.0" /> <PackageVersion Include="NUnit" Version="4.3.2" /> <PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" /> - <PackageVersion Include="SkiaSharp" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.NativeAssets.iOS" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Skottie" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.Blazor" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.Maui.Controls" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.Uno" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.Uno.WinUI" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.WPF" Version="3.119.2" /> - <PackageVersion Include="SkiaSharp.Views.WindowsForms" Version="3.119.2" /> + <PackageVersion Include="SkiaSharp" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.NativeAssets.iOS" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Skottie" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.Blazor" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.Maui.Controls" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.Uno.WinUI" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.WinUI" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.WPF" Version="3.119.4" /> + <PackageVersion Include="SkiaSharp.Views.WindowsForms" Version="3.119.4" /> <PackageVersion Include="sqlite-net-pcl" Version="1.9.172" /> <PackageVersion Include="SQLitePCLRaw.bundle_green" Version="2.1.10" /> <PackageVersion Include="SourceGear.sqlite3" Version="3.50.4.2" /> @@ -92,4 +91,4 @@ <PackageVersion Include="System.Text.Json" Version="9.0.0" /> <PackageVersion Include="VexTile.TileSource.Mvt" Version="2.0.0-beta.1" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000000..8e9347857e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,5 @@ +# Mapsui agent instructions + +This is a compatibility entrypoint for Gemini-based tools. + +@AGENTS.md diff --git a/Mapsui.ArcGIS/DynamicProvider/ArcGISDynamicProvider.cs b/Mapsui.ArcGIS/DynamicProvider/ArcGISDynamicProvider.cs index 48923499f7..cfcb32e0bd 100644 --- a/Mapsui.ArcGIS/DynamicProvider/ArcGISDynamicProvider.cs +++ b/Mapsui.ArcGIS/DynamicProvider/ArcGISDynamicProvider.cs @@ -241,8 +241,8 @@ private static string CreateSr(string? crs) if (crs == null) throw new Exception("crs type not supported"); - if (crs.StartsWith(CrsHelper.EsriStringPrefix)) return "{\"wkt\":\"" + crs[CrsHelper.EsriStringPrefix.Length..].Replace("\"", "\\\"") + "\"}"; - if (crs.StartsWith(CrsHelper.EpsgPrefix)) return CrsHelper.ToEpsgCode(crs).ToString(); + if (crs.StartsWith(CrsHelper.EsriStringPrefix, StringComparison.Ordinal)) return "{\"wkt\":\"" + crs[CrsHelper.EsriStringPrefix.Length..].Replace("\"", "\\\"") + "\"}"; + if (crs.StartsWith(CrsHelper.EpsgPrefix, StringComparison.Ordinal)) return CrsHelper.ToEpsgCode(crs).ToString(); throw new Exception("crs type not supported"); } @@ -252,7 +252,7 @@ private static string GetFormat(ArcGISDynamicCapabilities arcGisDynamicCapabilit if (arcGisDynamicCapabilities.supportedImageFormatTypes == null)//Not all services return supported types, use png return "png"; - var supportedTypes = arcGisDynamicCapabilities.supportedImageFormatTypes.ToLower(); + var supportedTypes = arcGisDynamicCapabilities.supportedImageFormatTypes.ToLowerInvariant(); if (supportedTypes.Contains("png32")) return "png32"; diff --git a/Mapsui.ArcGIS/ImageServiceProvider/ArcGISImageServiceProvider.cs b/Mapsui.ArcGIS/ImageServiceProvider/ArcGISImageServiceProvider.cs index 88531b13b7..bc062237b8 100644 --- a/Mapsui.ArcGIS/ImageServiceProvider/ArcGISImageServiceProvider.cs +++ b/Mapsui.ArcGIS/ImageServiceProvider/ArcGISImageServiceProvider.cs @@ -65,7 +65,7 @@ public string Url if (value[^1].Equals('/')) _url = value.Remove(value.Length - 1); - if (!_url.ToLower().Contains(@"exportimage")) + if (!_url.Contains("exportimage", StringComparison.OrdinalIgnoreCase)) _url += @"/ExportImage"; } } diff --git a/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaFontCache.cs b/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaFontCache.cs new file mode 100644 index 0000000000..71cd73358d --- /dev/null +++ b/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaFontCache.cs @@ -0,0 +1,52 @@ +using Mapsui.Rendering; +using Mapsui.Rendering.Caching; +using Mapsui.Styles; +using SkiaSharp; + +namespace Mapsui.Experimental.Rendering.Skia.Extensions; + +internal static class SkiaFontCache +{ + public static CacheTracker<SKFont> GetOrCreate(Font font, RenderService renderService) + => GetOrCreate(font, (float)font.Size, renderService); + + public static CacheTracker<SKFont> GetOrCreate(Font? font, float fallbackSize, RenderService renderService, + bool? boldOverride = null, SKFontEdging? edging = null) + { + var key = new FontCacheKey( + font?.FontSource, + font?.FontFamily, + font?.Size > 0 ? (float)font.Size : fallbackSize, + boldOverride ?? (font?.Bold == true), + font?.Italic == true, + edging); + + // Do not cache a fallback while asynchronously supplied font data is unavailable. + if (key.FontSource is not null && renderService.FontSourceCache.Get(key.FontSource) is null) +#pragma warning disable IDISP004 // CacheTracker owns and disposes the uncached SKFont. + return new CacheTracker<SKFont>(CreateFont(key, renderService)); +#pragma warning restore IDISP004 + + return renderService.VectorCache.GetOrCreate(key, CreateFont); + } + + private static SKFont CreateFont(FontCacheKey key, RenderService renderService) + { + var font = new Font + { + FontSource = key.FontSource, + FontFamily = key.FontFamily, + Size = key.Size, + Bold = key.Bold, + Italic = key.Italic, + }; + + var skFont = SkiaTextLayoutHelper.CreateSkFont(font, renderService); + if (key.Edging is not null) + skFont.Edging = key.Edging.Value; + return skFont; + } + + private readonly record struct FontCacheKey(FontSource? FontSource, string? FontFamily, float Size, bool Bold, + bool Italic, SKFontEdging? Edging); +} diff --git a/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs b/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs index 58f30897fb..b4c398ad68 100644 --- a/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs +++ b/Mapsui.Experimental.Rendering.Skia/Extensions/SkiaTextLayoutHelper.cs @@ -233,11 +233,6 @@ public static SKFont CreateSkFont(Mapsui.Styles.Font? font, float fallbackSize, var bytes = renderService.FontSourceCache.Get(fontSource); if (bytes != null) { - // Performance note: SKTypeface.FromStream is called on every draw frame for custom - // fonts because there is no typeface cache. A cache keyed by FontSource.SourceId - // would eliminate this cost. Caching is deferred because SKTypeface is Skia-specific - // while the natural cache owner (FontSourceCache) is platform-independent — - // ownership is non-trivial to resolve across that boundary. using var stream = new MemoryStream(bytes); return SKTypeface.FromStream(stream); } diff --git a/Mapsui.Experimental.Rendering.Skia/GridLayerRenderer.cs b/Mapsui.Experimental.Rendering.Skia/GridLayerRenderer.cs index 4b58b2208d..484dc2a93b 100644 --- a/Mapsui.Experimental.Rendering.Skia/GridLayerRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/GridLayerRenderer.cs @@ -64,7 +64,8 @@ private static void DrawLabels(SKCanvas canvas, Viewport viewport, MRect extent, var decimals = (int)Math.Max(0, Math.Ceiling(-Math.Log10(step))); var format = $"F{decimals}"; - using var font = SkiaTextLayoutHelper.CreateSkFont(gridLayer.LabelFont, gridLayer.LabelSize, renderService); + using var fontHolder = SkiaFontCache.GetOrCreate(gridLayer.LabelFont, gridLayer.LabelSize, renderService); + var font = fontHolder.Instance; var labelMargin = font.Size * 0.5f; using var textPaint = new SKPaint { Color = gridLayer.LabelColor.ToSkia(opacity), IsAntialias = true }; diff --git a/Mapsui.Experimental.Rendering.Skia/MapRenderer.cs b/Mapsui.Experimental.Rendering.Skia/MapRenderer.cs index 26b0b098c8..7f716d7576 100644 --- a/Mapsui.Experimental.Rendering.Skia/MapRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/MapRenderer.cs @@ -124,7 +124,7 @@ public void Render(object target, Viewport viewport, IEnumerable<ILayer> layers, surface.SKSurface.Canvas.Flush(); using var snapshot = surface.SKSurface.Snapshot(); - targetCanvas.DrawImage(snapshot, 0, 0); + targetCanvas.DrawImage(snapshot, 0, 0, SKSamplingOptions.Default); } // Threshold above which a "partial" update is treated as a full update to avoid the overhead diff --git a/Mapsui.Experimental.Rendering.Skia/Mapsui.Experimental.Rendering.Skia.csproj b/Mapsui.Experimental.Rendering.Skia/Mapsui.Experimental.Rendering.Skia.csproj index 3c16596389..097959117c 100644 --- a/Mapsui.Experimental.Rendering.Skia/Mapsui.Experimental.Rendering.Skia.csproj +++ b/Mapsui.Experimental.Rendering.Skia/Mapsui.Experimental.Rendering.Skia.csproj @@ -7,7 +7,7 @@ <IsPackable>true</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <!-- For experimental projects suppress: A stable release of a package should not have a prerelease dependency. --> - <NoWarn>NU5104</NoWarn> + <NoWarn>$(NoWarn);NU5104</NoWarn> </PropertyGroup> <ItemGroup> diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs index 56aa02acc9..be67eb7a17 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs @@ -95,7 +95,7 @@ public static SKPicture CreateCalloutContent(CalloutStyle callout, Mapsui.Render using var canvas = recorder.BeginRecording(new SKRect(0, 0, image.Width, image.Height)); using var paint = new SKPaint(); if (image is BitmapDrawableImage bitmapImage) - canvas.DrawImage(bitmapImage.Image, 0, 0, paint); + canvas.DrawImage(bitmapImage.Image, 0, 0, SKSamplingOptions.Default, paint); else if (image is SvgDrawableImage svgImage) canvas.DrawPicture(svgImage.Picture, paint); return recorder.EndRecording(); diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs index 1c5b0327ff..81b8e23458 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs @@ -78,7 +78,7 @@ private static IDrawableImage CreateLabelAsBitmap((LabelStyle Style, string? Tex { var style = valueTuple.Style; var layerOpacity = valueTuple.LayerOpacity; - using var fontHolder = renderService.VectorCache.GetOrCreate(style.Font, SkiaTextLayoutHelper.CreateSkFont); + using var fontHolder = SkiaFontCache.GetOrCreate(style.Font, renderService); using var paintHolder = renderService.VectorCache.GetOrCreate((style.ForeColor, layerOpacity), CreatePaint); var paint = paintHolder.Instance; var font = fontHolder.Instance; @@ -113,7 +113,7 @@ private static void DrawLabel(SKCanvas target, float x, float y, LabelStyle styl if (style.Font.FontSource != null && renderService.FontSourceCache.Get(style.Font.FontSource) == null) return; - using var fontHolder = renderService.VectorCache.GetOrCreate(style.Font, SkiaTextLayoutHelper.CreateSkFont); + using var fontHolder = SkiaFontCache.GetOrCreate(style.Font, renderService); using var paintHolder = renderService.VectorCache.GetOrCreate((style.ForeColor, layerOpacity), CreatePaint); var paint = paintHolder.Instance; var font = fontHolder.Instance; @@ -413,7 +413,7 @@ public static double FeatureSize(IFeature feature, LabelStyle labelStyle, Mapsui return labelStyle.Font.Size * (text?.Length ?? 1); // for measuring the text size the opacity can be set to 1 - using var fontHolder = renderService.VectorCache.GetOrCreate(labelStyle.Font, SkiaTextLayoutHelper.CreateSkFont); + using var fontHolder = SkiaFontCache.GetOrCreate(labelStyle.Font, renderService); using var paintHolder = labelStyle.Halo != null ? renderService.VectorCache.GetOrCreate((labelStyle, labelStyle.Halo), CreateHaloPaintHolder) : renderService.VectorCache.GetOrCreate((labelStyle.ForeColor, 1f), CreatePaint); diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs index d897e54c55..0bedb174d7 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs @@ -73,7 +73,7 @@ internal static SKPath CreatePath(SymbolType symbolType) TrianglePath(skPath, 0, 0, width); break; default: // Invalid value - throw new ArgumentException($"Unknown {nameof(SymbolType)} '{nameof(symbolType)}'"); + throw new ArgumentException($"Unknown {nameof(SymbolType)} '{symbolType}'"); } return skPath; diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/LoggingWidgetRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/LoggingWidgetRenderer.cs index 80ae374839..6e6bbb478c 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/LoggingWidgetRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/LoggingWidgetRenderer.cs @@ -54,7 +54,8 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rend (float)(rect.Right - loggingWidget.Padding.Right), (float)(rect.Bottom - loggingWidget.Padding.Bottom)); - using var font = SkiaTextLayoutHelper.CreateSkFont(loggingWidget.Font, (float)loggingWidget.TextSize, renderService); + using var fontHolder = SkiaFontCache.GetOrCreate(loggingWidget.Font, (float)loggingWidget.TextSize, renderService); + var font = fontHolder.Instance; var line = 0; var effectiveTextSize = font.Size; diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/PerformanceWidgetRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/PerformanceWidgetRenderer.cs index 79779a1f53..26df840093 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/PerformanceWidgetRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/PerformanceWidgetRenderer.cs @@ -18,7 +18,8 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rend var textSize = performanceWidget.TextSize; - using var font = SkiaTextLayoutHelper.CreateSkFont(performanceWidget.Font, (float)textSize, renderService); + using var fontHolder = SkiaFontCache.GetOrCreate(performanceWidget.Font, (float)textSize, renderService); + var font = fontHolder.Instance; // Use the actual font size for layout so Font.Size overrides TextSize correctly. var effectiveTextSize = font.Size; using var textPaint = new SKPaint { Color = performanceWidget.TextColor.ToSkia() }; diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs index c278433669..e5cc6a8864 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs @@ -1,8 +1,6 @@ using System; -using System.IO; using System.Linq; using Mapsui.Experimental.Rendering.Skia.Extensions; -using Mapsui.Styles; using Mapsui.Widgets; using Mapsui.Widgets.ScaleBar; using SkiaSharp; @@ -14,9 +12,7 @@ public class ScaleBarWidgetRenderer : ISkiaWidgetRenderer, IDisposable private readonly SKPaint _paintScaleBar = CreateScaleBarPaint(SKPaintStyle.Fill); private readonly SKPaint _paintScaleBarStroke = CreateScaleBarPaint(SKPaintStyle.Stroke); private readonly SKPaint _paintScaleText = CreateTextPaint(SKPaintStyle.Fill); - private readonly SKFont _paintScaleTextFont = CreateFont(); private readonly SKPaint _paintScaleTextStroke = CreateTextPaint(SKPaintStyle.Stroke); - private readonly SKFont _paintScaleTextStrokeFont = CreateFont(); public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rendering.RenderService renderService, float layerOpacity, SKRect? dirtyScreenRect) @@ -31,13 +27,12 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rend _paintScaleBarStroke.StrokeWidth = (float)(scaleBar.StrokeWidthHalo * scaleBar.Scale); _paintScaleText!.Color = scaleBar.TextColor.ToSkia(layerOpacity); _paintScaleText.StrokeWidth = (float)(scaleBar.StrokeWidth * scaleBar.Scale); - var typeface = CreateTypeface(scaleBar.Font, renderService); - _paintScaleTextFont.Typeface = typeface; - _paintScaleTextFont.Size = (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale); _paintScaleTextStroke!.Color = scaleBar.Halo.ToSkia(layerOpacity); _paintScaleTextStroke.StrokeWidth = (float)(scaleBar.StrokeWidthHalo / 2 * scaleBar.Scale); - _paintScaleTextStrokeFont.Typeface = typeface; - _paintScaleTextStrokeFont.Size = (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale); + using var fontHolder = SkiaFontCache.GetOrCreate(scaleBar.Font, + (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale), renderService, + boldOverride: true, edging: SKFontEdging.SubpixelAntialias); + var scaleTextFont = fontHolder.Instance; double scaleBarLength1; string? scaleBarText1; @@ -49,7 +44,7 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rend // Calc height of scale bar // Do this, because height of text changes sometimes (e.g. from 2 m to 1 m) - _paintScaleTextStrokeFont.MeasureText("9999 m", out var textSize, _paintScaleTextStroke); + scaleTextFont.MeasureText("9999 m", out var textSize, _paintScaleTextStroke); var scaleBarHeight = textSize.Height + (scaleBar.TickLength + scaleBar.StrokeWidthHalo * 0.5f + ScaleBarWidget.TextMargin) * scaleBar.Scale; @@ -92,28 +87,28 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, Mapsui.Rend var textSize2 = SKRect.Empty; scaleBarText1 ??= string.Empty; - _paintScaleTextStrokeFont.MeasureText(scaleBarText1, out var textSize1, _paintScaleTextStroke); + scaleTextFont.MeasureText(scaleBarText1, out var textSize1, _paintScaleTextStroke); if (scaleBar.ScaleBarMode == ScaleBarMode.Both && scaleBar.SecondaryUnitConverter != null) { // If there is SecondaryUnitConverter we need to calculate the size before passing it into GetScaleBarTextPositions scaleBarText2 ??= string.Empty; - _paintScaleTextStrokeFont.MeasureText(scaleBarText2, out textSize2, _paintScaleTextStroke); + scaleTextFont.MeasureText(scaleBarText2, out textSize2, _paintScaleTextStroke); } var (posX1, posY1, posX2, posY2) = scaleBar.GetScaleBarTextPositions(viewport, textSize1.ToMRect(), textSize2.ToMRect(), scaleBar.StrokeWidthHalo); // Now draw text - canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, _paintScaleTextStrokeFont, _paintScaleTextStroke); - canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, _paintScaleTextFont, _paintScaleText); + canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, scaleTextFont, _paintScaleTextStroke); + canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, scaleTextFont, _paintScaleText); envelop = envelop?.Join(new MRect(posX1, posY1, posX1 + textSize1.Width, posY1 + textSize1.Height)); if (scaleBar.ScaleBarMode == ScaleBarMode.Both && scaleBar.SecondaryUnitConverter != null) { // Now draw second text - canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, _paintScaleTextStrokeFont, _paintScaleTextStroke); - canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, _paintScaleTextFont, _paintScaleText); + canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, scaleTextFont, _paintScaleTextStroke); + canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, scaleTextFont, _paintScaleText); envelop = envelop?.Join(new MRect(posX2, posY2, posX2 + textSize2.Width, posY2 + textSize2.Height)); } @@ -146,35 +141,6 @@ private static SKPaint CreateTextPaint(SKPaintStyle style) }; } - private static SKTypeface CreateTypeface(Font? font, Mapsui.Rendering.RenderService renderService) - { - if (font?.FontSource != null) - { - var bytes = renderService.FontSourceCache.Get(font.FontSource); - if (bytes != null) - { - // Performance note: SKTypeface.FromStream is called on every draw frame for custom - // fonts because there is no typeface cache. A cache keyed by FontSource.SourceId - // would eliminate this cost. Caching is deferred because SKTypeface is Skia-specific - // while the natural cache owner (FontSourceCache) is platform-independent — - // ownership is non-trivial to resolve across that boundary. - using var stream = new MemoryStream(bytes); - return SKTypeface.FromStream(stream); - } - } - - return SKTypeface.FromFamilyName(font?.FontFamily, - SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - } - - private static SKFont CreateFont() - { - return new SKFont() - { - Edging = SKFontEdging.SubpixelAntialias, - }; - } - public void Dispose() { Dispose(true); @@ -188,9 +154,7 @@ protected virtual void Dispose(bool disposing) _paintScaleBar.Dispose(); _paintScaleBarStroke.Dispose(); _paintScaleText.Dispose(); - _paintScaleTextFont.Dispose(); _paintScaleTextStroke.Dispose(); - _paintScaleTextStrokeFont.Dispose(); } } } diff --git a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/TextBoxWidgetRenderer.cs b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/TextBoxWidgetRenderer.cs index d52a28f691..77643f5006 100644 --- a/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/TextBoxWidgetRenderer.cs +++ b/Mapsui.Experimental.Rendering.Skia/SkiaWidgets/TextBoxWidgetRenderer.cs @@ -19,7 +19,8 @@ public static void DrawText(SKCanvas canvas, Viewport viewport, IWidget widget, if (string.IsNullOrEmpty(textBox.Text)) return; - using var skFont = SkiaTextLayoutHelper.CreateSkFont(textBox.Font, (float)textBox.TextSize, renderService); + using var fontHolder = SkiaFontCache.GetOrCreate(textBox.Font, (float)textBox.TextSize, renderService); + var skFont = fontHolder.Instance; using var textPaint = new SKPaint { Color = textBox.TextColor.ToSkia(layerOpacity), IsAntialias = true }; using var backPaint = new SKPaint { Color = textBox.BackColor.ToSkia(layerOpacity), IsAntialias = true }; // The textRect has an offset which can be confusing. diff --git a/Mapsui.Experimental.VectorTiles/Mapsui.Experimental.VectorTiles.csproj b/Mapsui.Experimental.VectorTiles/Mapsui.Experimental.VectorTiles.csproj index 91e8e87872..2a26d30fdb 100644 --- a/Mapsui.Experimental.VectorTiles/Mapsui.Experimental.VectorTiles.csproj +++ b/Mapsui.Experimental.VectorTiles/Mapsui.Experimental.VectorTiles.csproj @@ -7,8 +7,9 @@ <IsPackable>true</IsPackable> <Nullable>enable</Nullable> <PackageReadmeFile>README.md</PackageReadmeFile> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <!-- For experimental projects suppress: A stable release of a package should not have a prerelease dependency. --> - <NoWarn>NU5104</NoWarn> + <NoWarn>$(NoWarn);NU5104</NoWarn> </PropertyGroup> <ItemGroup> diff --git a/Mapsui.Experimental.VectorTiles/Rendering/VexTileRenderer.cs b/Mapsui.Experimental.VectorTiles/Rendering/VexTileRenderer.cs index b93d01d080..bb2b45dc6e 100644 --- a/Mapsui.Experimental.VectorTiles/Rendering/VexTileRenderer.cs +++ b/Mapsui.Experimental.VectorTiles/Rendering/VexTileRenderer.cs @@ -111,10 +111,16 @@ public static void Render( foreach (var tileLayer in vectorTile.Layers) { - if (!categorizedVectorLayers.TryGetValue(tileLayer.Name, out var layerList)) + // A layer without a name can never be matched against a style layer, and indexing the + // dictionary with null would throw. + var layerName = tileLayer.Name; + if (layerName is null) + continue; + + if (!categorizedVectorLayers.TryGetValue(layerName, out var layerList)) { layerList = new List<VectorTileLayer>(); - categorizedVectorLayers[tileLayer.Name] = layerList; + categorizedVectorLayers[layerName] = layerList; } layerList.Add(tileLayer); } @@ -147,9 +153,14 @@ public static void Render( { attributes.Clear(); foreach (var kvp in feature.Attributes) - attributes[kvp.Key] = kvp.Value; - attributes["$type"] = feature.GeometryType; - attributes["$id"] = layer.ID; + { + // The attribute dictionary the style filters read from does not + // accept nulls; a missing key and a null value mean the same here. + if (kvp.Value is not null) + attributes[kvp.Key] = kvp.Value; + } + attributes["$type"] = feature.GeometryType ?? string.Empty; + attributes["$id"] = layer.ID ?? string.Empty; attributes["$zoom"] = actualZoom; if (style.ValidateLayer(layer, actualZoom, attributes)) @@ -168,7 +179,7 @@ public static void Render( VectorTileFeature = feature, Geometry = feature.Geometry, Brush = brush, - LayerId = layer.ID, + LayerId = layer.ID ?? string.Empty, SourceName = layer.SourceName, SourceLayer = layer.SourceLayer, InsertionOrder = visualLayers.Count, @@ -272,7 +283,8 @@ private static void RenderVisualLayers(ICanvas canvas, List<VisualLayer> visualL } else if (layer.Type == VisualLayerType.Raster) { - canvas.DrawImage(layer.RasterData, layer.Brush); + if (layer.RasterData is not null) + canvas.DrawImage(layer.RasterData, layer.Brush); } } diff --git a/Mapsui.Experimental.VectorTiles/VexTileCopies/.editorconfig b/Mapsui.Experimental.VectorTiles/VexTileCopies/.editorconfig new file mode 100644 index 0000000000..be8af544ec --- /dev/null +++ b/Mapsui.Experimental.VectorTiles/VexTileCopies/.editorconfig @@ -0,0 +1,19 @@ +# Vendored copies of upstream VexTile (https://github.com/bertt/VexTile). +# +# These files are deliberately kept as close to upstream as possible so they can be re-synced, +# so their pre-existing nullable-reference debt is not treated as an error here. The project +# enables TreatWarningsAsErrors, and Mapsui's own code in this project is held to that full bar - +# this relaxation applies only to the vendored files in this folder. +# +# Note: this is scoped via .editorconfig rather than <NoWarn> because NoWarn is an assembly-level +# compiler switch and cannot be applied to a subset of files. + +[*.cs] +dotnet_diagnostic.CS8600.severity = none # Converting null literal or possible null value to non-nullable type +dotnet_diagnostic.CS8601.severity = none # Possible null reference assignment +dotnet_diagnostic.CS8602.severity = none # Dereference of a possibly null reference +dotnet_diagnostic.CS8603.severity = none # Possible null reference return +dotnet_diagnostic.CS8604.severity = none # Possible null reference argument +dotnet_diagnostic.CS8605.severity = none # Unboxing a possibly null value +dotnet_diagnostic.CS8618.severity = none # Non-nullable field must contain a non-null value when exiting constructor +dotnet_diagnostic.CS8625.severity = none # Cannot convert null literal to non-nullable reference type diff --git a/Mapsui.Experimental.VectorTiles/VexTileCopies/SkiaCanvas.cs b/Mapsui.Experimental.VectorTiles/VexTileCopies/SkiaCanvas.cs index 8c09c5268c..6a62d3aa0a 100644 --- a/Mapsui.Experimental.VectorTiles/VexTileCopies/SkiaCanvas.cs +++ b/Mapsui.Experimental.VectorTiles/VexTileCopies/SkiaCanvas.cs @@ -443,9 +443,9 @@ internal string TransformText(string text, Brush style, bool wordWrap = true) return string.Empty; if (style.Paint.TextTransform == TextTransform.Uppercase) - text = text.ToUpper(); + text = text.ToUpperInvariant(); else if (style.Paint.TextTransform == TextTransform.Lowercase) - text = text.ToLower(); + text = text.ToLowerInvariant(); if (wordWrap) { @@ -530,13 +530,17 @@ private static SKTypeface GetFont(string[] familyNames, Brush style) private static SKTypeface QualifyTypeface(Brush style, SKTypeface typeface) { // CountGlyphs returns an int directly — no array allocation needed. - int glyphCount = typeface.CountGlyphs(style.Text); + // Called on SKFont rather than SKTypeface: SkiaSharp 4 obsoletes SKTypeface.CountGlyphs, + // and SKFont.CountGlyphs exists in both 3.x and 4.x, so this stays valid on either major. + using var font = new SKFont(typeface); + int glyphCount = font.CountGlyphs(style.Text); if (glyphCount >= style.Text.Length) return typeface; SKFontManager sKFontManager = SKFontManager.Default; using var fallbackTypeface = sKFontManager.MatchCharacter(style.Text[glyphCount]); - int fallbackGlyphCount = fallbackTypeface.CountGlyphs(style.Text); + using var fallbackFont = new SKFont(fallbackTypeface); + int fallbackGlyphCount = fallbackFont.CountGlyphs(style.Text); if (fallbackGlyphCount < style.Text.Length) style.Text = style.Text[..fallbackGlyphCount]; return typeface; diff --git a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyle.cs b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyle.cs index 1fa5b94580..46d436d4f4 100644 --- a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyle.cs +++ b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyle.cs @@ -397,7 +397,7 @@ private static double MoveIntoRange(double temp3) private SKColor ParseColor(object iColor) { - var provider = new CultureInfo("en-US", useUserOverride: true); + var provider = CultureInfo.InvariantCulture; if (iColor is Color color) return SKColorFactory.MakeColor(color.R, color.G, color.B, color.A); if (iColor is SKColor color2) @@ -405,7 +405,7 @@ private SKColor ParseColor(object iColor) var text = (string)iColor; if (text[0] == '#') return SKColorFactory.LogColor(SKColor.Parse(text)); - if (text.StartsWith("hsl(")) + if (text.StartsWith("hsl(", StringComparison.Ordinal)) { var array = text.Replace('%', '\0').Split(new char[] { ',', '(', ')' }); var th = double.Parse(array[1], provider); @@ -413,7 +413,7 @@ private SKColor ParseColor(object iColor) var tl = double.Parse(array[3], provider); return HslaToColor(255.0, th, ts, tl); } - if (text.StartsWith("hsla(")) + if (text.StartsWith("hsla(", StringComparison.Ordinal)) { var array2 = text.Replace('%', '\0').Split(new char[] { ',', '(', ')' }); return HslaToColor( @@ -422,7 +422,7 @@ private SKColor ParseColor(object iColor) ts: double.Parse(array2[2], provider), tl: double.Parse(array2[3], provider)); } - if (text.StartsWith("rgba(")) + if (text.StartsWith("rgba(", StringComparison.Ordinal)) { var array3 = text.Replace('%', '\0').Split(new char[] { ',', '(', ')' }); var num = double.Parse(array3[1], provider); @@ -431,7 +431,7 @@ private SKColor ParseColor(object iColor) var num4 = double.Parse(array3[4], provider) * 255.0; return SKColorFactory.MakeColor((byte)num, (byte)num2, (byte)num3, (byte)num4); } - if (text.StartsWith("rgb(")) + if (text.StartsWith("rgb(", StringComparison.Ordinal)) { var array4 = text.Replace('%', '\0').Split(new char[] { ',', '(', ')' }); var num5 = double.Parse(array4[1], provider); diff --git a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyleReader.cs b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyleReader.cs index 6759e9db38..cf9f07e213 100644 --- a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyleReader.cs +++ b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorStyleReader.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using NLog; @@ -13,7 +14,7 @@ public static class VectorStyleReader public static string GetStyle(VectorStyleKind styleKind) { - var text = styleKind.ToString().ToLower(); + var text = styleKind.ToString().ToLowerInvariant(); // Read from the AliFlux assembly which contains the embedded style resources var aliFluxAssembly = typeof(VexTile.Renderer.Mvt.AliFlux.IVectorStyle).Assembly; var name = aliFluxAssembly.GetName().Name; @@ -36,7 +37,7 @@ public static bool TryGetFont(string name, out Stream stream) var resourceName = name2 + ".Styles.fonts." + name; if (names == null) names = aliFluxAssembly.GetManifestResourceNames(); - var text = names?.FirstOrDefault(x => x.StartsWith(resourceName)); + var text = names?.FirstOrDefault(x => x.StartsWith(resourceName, StringComparison.Ordinal)); if (!string.IsNullOrWhiteSpace(text)) { using var stream2 = aliFluxAssembly.GetManifestResourceStream(text); diff --git a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorTilesSource.cs b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorTilesSource.cs index a2ea28b608..44a1d963e0 100644 --- a/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorTilesSource.cs +++ b/Mapsui.Experimental.VectorTiles/VexTileCopies/VectorTilesSource.cs @@ -1,6 +1,7 @@ using System; -using NLog; +using System.Globalization; using System.Threading.Tasks; +using NLog; using VexTile.Common.Data; using VexTile.Common.Sources; using VexTile.Renderer.Mvt.AliFlux.Drawing; @@ -40,17 +41,17 @@ private void LoadMetadata() { foreach (IMetaData metaDatum in _sharedDataSource.GetMetaData()) { - switch (metaDatum.Name.ToLower()) + switch (metaDatum.Name.ToLowerInvariant()) { case "bounds": { string[] array = metaDatum.Value.Split(','); Bounds = new GeoExtent { - West = Convert.ToDouble(array[0]), - South = Convert.ToDouble(array[1]), - East = Convert.ToDouble(array[2]), - North = Convert.ToDouble(array[3]) + West = Convert.ToDouble(array[0], CultureInfo.InvariantCulture), + South = Convert.ToDouble(array[1], CultureInfo.InvariantCulture), + East = Convert.ToDouble(array[2], CultureInfo.InvariantCulture), + North = Convert.ToDouble(array[3], CultureInfo.InvariantCulture) }; break; } @@ -59,16 +60,16 @@ private void LoadMetadata() string[] array = metaDatum.Value.Split(','); Center = new CoordinatePair { - X = Convert.ToDouble(array[0]), - Y = Convert.ToDouble(array[1]) + X = Convert.ToDouble(array[0], CultureInfo.InvariantCulture), + Y = Convert.ToDouble(array[1], CultureInfo.InvariantCulture) }; break; } case "minzoom": - MinZoom = Convert.ToInt32(metaDatum.Value); + MinZoom = Convert.ToInt32(metaDatum.Value, CultureInfo.InvariantCulture); break; case "maxzoom": - MaxZoom = Convert.ToInt32(metaDatum.Value); + MaxZoom = Convert.ToInt32(metaDatum.Value, CultureInfo.InvariantCulture); break; case "name": Name = metaDatum.Value; diff --git a/Mapsui.Experimental/Layers/ObservableCollectionLayer.cs b/Mapsui.Experimental/Layers/ObservableCollectionLayer.cs index 8218191cbd..46432a53a9 100644 --- a/Mapsui.Experimental/Layers/ObservableCollectionLayer.cs +++ b/Mapsui.Experimental/Layers/ObservableCollectionLayer.cs @@ -3,7 +3,6 @@ using System.Collections.Specialized; using System.Linq; using Mapsui.Extensions; -using Mapsui.Styles; using Mapsui.Utilities; using System.Collections.Generic; using Mapsui.Layers; @@ -135,9 +134,7 @@ public override IEnumerable<IFeature> GetFeatures(MRect? rect, double resolution if (rect == null) yield break; - var biggerRect = rect.Grow( - SymbolStyle.DefaultWidth * 2 * resolution, - SymbolStyle.DefaultHeight * 2 * resolution); + var biggerRect = rect.Grow(VisibilityMargin * resolution); foreach (var feature in _shadowCollection.Select(i => i.Feature).ToArray()) { if (feature?.Extent?.Intersects(biggerRect) == true) diff --git a/Mapsui.Extensions/Cache/SqlitePersistentCache.cs b/Mapsui.Extensions/Cache/SqlitePersistentCache.cs index 87357e505e..abff7dbc25 100644 --- a/Mapsui.Extensions/Cache/SqlitePersistentCache.cs +++ b/Mapsui.Extensions/Cache/SqlitePersistentCache.cs @@ -268,7 +268,7 @@ public void Remove(string url, byte[]? postData) if (bytes == null || string.IsNullOrEmpty(compression) || string.Equals(compression, _noCompression, StringComparison.InvariantCultureIgnoreCase)) return bytes; - switch (compression.ToLower()) + switch (compression.ToLowerInvariant()) { case _brotliCompression: { diff --git a/Mapsui.Extensions/Provider/GeoTiffProvider.cs b/Mapsui.Extensions/Provider/GeoTiffProvider.cs index 4c0cb9535c..e6cf7c29ef 100644 --- a/Mapsui.Extensions/Provider/GeoTiffProvider.cs +++ b/Mapsui.Extensions/Provider/GeoTiffProvider.cs @@ -266,7 +266,7 @@ private static string GetPathWithoutExtension(string path) public bool? IsCrsSupported(string? crs) { - return string.Equals(crs?.Trim(), CRS?.Trim(), StringComparison.CurrentCultureIgnoreCase); + return string.Equals(crs?.Trim(), CRS?.Trim(), StringComparison.OrdinalIgnoreCase); } public virtual void Dispose() diff --git a/Mapsui.Nts/Editing/EditManager.cs b/Mapsui.Nts/Editing/EditManager.cs index f1a300134b..9dd942a5b4 100644 --- a/Mapsui.Nts/Editing/EditManager.cs +++ b/Mapsui.Nts/Editing/EditManager.cs @@ -45,10 +45,7 @@ public bool EndEdit() if (_addInfo.Vertices.Count < 2) // If there are not enough vertices, do not add the feature { - // And reset it back to the previous state. - Layer?.TryRemove(_addInfo.Feature); - _addInfo.Reset(); - EditMode = EditMode.AddLine; + CancelAdd(EditMode.AddLine); return false; } @@ -64,6 +61,13 @@ public bool EndEdit() if (polygon == null) return false; _addInfo.Vertices.RemoveAt(_addInfo.Vertices.Count - 1); // Remove the last vertex, because it is the hover vertex + + if (_addInfo.Vertices.Count < 3) // A polygon ring needs at least 3 distinct points, otherwise LinearRing throws. + { + CancelAdd(EditMode.AddPolygon); + return false; + } + var linearRing = _addInfo.Vertices.ToList(); linearRing.Add(linearRing[0].Copy()); // Add first coordinate at end to close the ring. _addInfo.Feature.Geometry = new Polygon(new LinearRing(linearRing.ToArray())); @@ -78,6 +82,16 @@ public bool EndEdit() return false; } + private void CancelAdd(EditMode nextMode) + { + if (_addInfo.Feature != null) + Layer?.TryRemove(_addInfo.Feature); + + _addInfo.Reset(); + EditMode = nextMode; + Layer?.DataHasChanged(); + } + public void HoveringVertex(MapInfo mapInfo) { if (_addInfo.Vertex != null) diff --git a/Mapsui.Nts/Providers/GeoJsonProvider.cs b/Mapsui.Nts/Providers/GeoJsonProvider.cs index c6e5c6cbdf..338c16316d 100644 --- a/Mapsui.Nts/Providers/GeoJsonProvider.cs +++ b/Mapsui.Nts/Providers/GeoJsonProvider.cs @@ -76,10 +76,10 @@ private bool IsGeoJsonContent() if (string.IsNullOrWhiteSpace(_geoJson)) return false; - return (_geoJson.Contains('{', StringComparison.CurrentCulture) - && _geoJson.Contains('}', StringComparison.CurrentCulture)) - || (_geoJson.Contains('[', StringComparison.CurrentCulture) - && _geoJson.Contains(']', StringComparison.CurrentCulture)); + return (_geoJson.Contains('{') + && _geoJson.Contains('}')) + || (_geoJson.Contains('[') + && _geoJson.Contains(']')); } public FeatureKeyCreator<string> FeatureKeyCreator diff --git a/Mapsui.Nts/Providers/Shapefile/Indexing/BinaryTree.cs b/Mapsui.Nts/Providers/Shapefile/Indexing/BinaryTree.cs index 1c51cd9bdd..2d29278b64 100644 --- a/Mapsui.Nts/Providers/Shapefile/Indexing/BinaryTree.cs +++ b/Mapsui.Nts/Providers/Shapefile/Indexing/BinaryTree.cs @@ -162,7 +162,7 @@ public IEnumerable<ItemValue> Between(T min, T max) /// <returns>Enumerator</returns> public IEnumerable<ItemValue> StartsWith(string str) { - return ScanString(str.ToUpper(), _root.RightNode); + return ScanString(str.ToUpperInvariant(), _root.RightNode); } /// <summary> @@ -199,19 +199,19 @@ private IEnumerable<ItemValue> ScanString(string val, Node<T, TU>? root) { if (root == null) yield break; - if (string.Compare(root.Item.Value?.ToString()?[..val.Length].ToUpper(), + if (string.Compare(root.Item.Value?.ToString()?[..val.Length].ToUpperInvariant(), val, StringComparison.Ordinal) > 0) if (root.LeftNode != null) - if (root.LeftNode.Item.Value?.ToString()?.ToUpper().StartsWith(val) ?? false) + if (root.LeftNode.Item.Value?.ToString()?.ToUpperInvariant().StartsWith(val, StringComparison.Ordinal) ?? false) foreach (var item in ScanString(val, root.LeftNode)) yield return item; - if (root.Item.Value?.ToString()?.ToUpper().StartsWith(val) ?? false) + if (root.Item.Value?.ToString()?.ToUpperInvariant().StartsWith(val, StringComparison.Ordinal) ?? false) yield return root.Item; if (string.Compare(root.Item.Value?.ToString(), val, StringComparison.Ordinal) < 0) if (root.RightNode != null) - if (string.Compare(root.RightNode.Item.Value?.ToString()?[..val.Length].ToUpper(), val, StringComparison.Ordinal) > 0) + if (string.Compare(root.RightNode.Item.Value?.ToString()?[..val.Length].ToUpperInvariant(), val, StringComparison.Ordinal) > 0) foreach (var item in ScanString(val, root.RightNode)) yield return item; } diff --git a/Mapsui.Nts/Providers/Shapefile/ShapeFile.cs b/Mapsui.Nts/Providers/Shapefile/ShapeFile.cs index 08743ee5e8..31de7d1520 100644 --- a/Mapsui.Nts/Providers/Shapefile/ShapeFile.cs +++ b/Mapsui.Nts/Providers/Shapefile/ShapeFile.cs @@ -500,7 +500,7 @@ private void InitializeShape(string filename, bool fileBasedIndex) { if (!File.Exists(filename)) throw new FileNotFoundException($"Could not find file \"{filename}\""); - if (!filename.ToLower().EndsWith(".shp")) + if (!filename.EndsWith(".shp", StringComparison.OrdinalIgnoreCase)) throw new Exception("Invalid shapefile filename: " + filename); LoadSpatialIndex(fileBasedIndex); //Load spatial index diff --git a/Mapsui.Nts/Providers/Wfs/Utilities/UrlQueryHelper.cs b/Mapsui.Nts/Providers/Wfs/Utilities/UrlQueryHelper.cs index e530f1aaff..895d625d00 100644 --- a/Mapsui.Nts/Providers/Wfs/Utilities/UrlQueryHelper.cs +++ b/Mapsui.Nts/Providers/Wfs/Utilities/UrlQueryHelper.cs @@ -9,7 +9,7 @@ public static class UrlQueryHelper public static string AppendQuery(this string url, string query) { // remove lending / - if (url.EndsWith(@"/")) + if (url.EndsWith('/')) { url = url[..^1]; } diff --git a/Mapsui.Nts/Providers/Wfs/WFSProvider.cs b/Mapsui.Nts/Providers/Wfs/WFSProvider.cs index f2841820c6..2035301fb0 100644 --- a/Mapsui.Nts/Providers/Wfs/WFSProvider.cs +++ b/Mapsui.Nts/Providers/Wfs/WFSProvider.cs @@ -924,7 +924,7 @@ await describeFeatureTypeQueryManager.SetDocumentToParseAsync(config.ConfigureFo node.MoveToAttribute("type", string.Empty); var type = node.Value; - if (type.StartsWith("gml:")) // we skip geometry element cause we already found it + if (type.StartsWith("gml:", StringComparison.Ordinal)) // we skip geometry element cause we already found it continue; node.MoveToParent(); diff --git a/Mapsui.Rendering.Skia/Extensions/SkiaFontCache.cs b/Mapsui.Rendering.Skia/Extensions/SkiaFontCache.cs new file mode 100644 index 0000000000..8c21eac98f --- /dev/null +++ b/Mapsui.Rendering.Skia/Extensions/SkiaFontCache.cs @@ -0,0 +1,49 @@ +using Mapsui.Rendering.Caching; +using Mapsui.Styles; +using SkiaSharp; + +namespace Mapsui.Rendering.Skia.Extensions; + +internal static class SkiaFontCache +{ + public static CacheTracker<SKFont> GetOrCreate(Font font, RenderService renderService) + => GetOrCreate(font, (float)font.Size, renderService); + + public static CacheTracker<SKFont> GetOrCreate(Font? font, float fallbackSize, RenderService renderService, + bool? boldOverride = null, SKFontEdging? edging = null) + { + var key = new FontCacheKey( + font?.FontFamily, + font?.Size > 0 ? (float)font.Size : fallbackSize, + boldOverride ?? (font?.Bold == true), + font?.Italic == true, + edging); + + return renderService.VectorCache.GetOrCreate(key, CreateFont); + } + + private static SKFont CreateFont(FontCacheKey key) + { + var typeface = key.FontFamily is null + ? null + : SKTypeface.FromFamilyName(key.FontFamily, + key.Bold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + key.Italic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + + var font = new SKFont + { + Embolden = false, + Size = key.Size, + Typeface = typeface, + }; + + if (key.Edging is not null) + font.Edging = key.Edging.Value; + + return font; + } + + private readonly record struct FontCacheKey(string? FontFamily, float Size, bool Bold, bool Italic, + SKFontEdging? Edging); +} diff --git a/Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs b/Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs index c211ed1a3e..1babc45e83 100644 --- a/Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaStyles/CalloutStyleRenderer.cs @@ -93,7 +93,7 @@ public static SKPicture CreateCalloutContent(CalloutStyle callout, RenderService using var canvas = recorder.BeginRecording(new SKRect(0, 0, image.Width, image.Height)); using var paint = new SKPaint(); if (image is BitmapDrawableImage bitmapImage) - canvas.DrawImage(bitmapImage.Image, 0, 0, paint); + canvas.DrawImage(bitmapImage.Image, 0, 0, SKSamplingOptions.Default, paint); else if (image is SvgDrawableImage svgImage) canvas.DrawPicture(svgImage.Picture, paint); return recorder.EndRecording(); diff --git a/Mapsui.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs b/Mapsui.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs index b64be6618d..e68bc03bb8 100644 --- a/Mapsui.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaStyles/LabelStyleRenderer.cs @@ -77,7 +77,7 @@ private static IDrawableImage CreateLabelAsBitmap((LabelStyle Style, string? Tex { var style = valueTuple.Style; var layerOpacity = valueTuple.LayerOpacity; - using var fontHolder = renderService.VectorCache.GetOrCreate(style.Font, CreateFont); + using var fontHolder = SkiaFontCache.GetOrCreate(style.Font, renderService); using var paintHolder = renderService.VectorCache.GetOrCreate((style.ForeColor, layerOpacity), CreatePaint); var paint = paintHolder.Instance; var font = fontHolder.Instance; @@ -106,7 +106,7 @@ private static SKImage CreateLabelAsImage(LabelStyle style, string? text, SKFont private static void DrawLabel(SKCanvas target, float x, float y, LabelStyle style, string? text, float layerOpacity, RenderService renderService) { - using var fontHolder = renderService.VectorCache.GetOrCreate(style.Font, CreateFont); + using var fontHolder = SkiaFontCache.GetOrCreate(style.Font, renderService); using var paintHolder = renderService.VectorCache.GetOrCreate((style.ForeColor, layerOpacity), CreatePaint); var paint = paintHolder.Instance; var font = fontHolder.Instance; @@ -352,27 +352,6 @@ private static SKPaint CreatePaint((Color ForeColor, float LayerOpacity) style) return paint; } - private static SKFont CreateFont(Font font) - { - var typeface = SKTypeface.FromFamilyName(font.FontFamily, - font.Bold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, - SKFontStyleWidth.Normal, - font.Italic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); - - SKFont skFont = new() - { - Embolden = false, - //EmbeddedBitmaps = true, - Size = (float)font.Size, - Typeface = typeface, - //Subpixel = true, - //Hinting = SKFontHinting.None, - //Edging = SKFontEdging.Antialias, - }; - - return skFont; - } - private static SKPaint CreatePaint((Font Font, Color ForeColor, float LayerOpacity, SKPaintStyle PaintStyle, float StrokeWidth) style) { var paint = CreatePaint((style.ForeColor, style.LayerOpacity)); @@ -462,7 +441,7 @@ public static double FeatureSize(IFeature feature, LabelStyle labelStyle, Render return 0; // for measuring the text size the opacity can be set to 1try - using var fontHolder = renderService.VectorCache.GetOrCreate(labelStyle.Font, CreateFont); + using var fontHolder = SkiaFontCache.GetOrCreate(labelStyle.Font, renderService); using var paintHolder = labelStyle.Halo != null ? renderService.VectorCache.GetOrCreate((labelStyle, labelStyle.Halo), CreateHaloPaintHolder) : renderService.VectorCache.GetOrCreate((labelStyle.ForeColor, 1f), CreatePaint); diff --git a/Mapsui.Rendering.Skia/SkiaStyles/LineStringRenderer.cs b/Mapsui.Rendering.Skia/SkiaStyles/LineStringRenderer.cs index 0bae5308b4..5a4dd9594e 100644 --- a/Mapsui.Rendering.Skia/SkiaStyles/LineStringRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaStyles/LineStringRenderer.cs @@ -15,7 +15,7 @@ public static void Draw(SKCanvas canvas, Viewport viewport, VectorStyle? vectorS return; // lineString - relevant for GeometryCollection children - SKPath ToPath((long featureId, int position, MRect extent, double rotation, float lineWidth) valueTuple) + SKPath ToPath((long featureGenerationId, int position, MRect extent, double rotation, float lineWidth) valueTuple) { var result = lineString.ToSkiaPath(viewport, viewport.ToSkiaRect(), valueTuple.lineWidth); _ = result.Bounds; @@ -29,7 +29,7 @@ SKPath ToPath((long featureId, int position, MRect extent, double rotation, floa if (vectorStyle.Line.IsVisible()) { - using var path = renderService.VectorCache.GetOrCreate((feature.Id, position, extent, rotation, lineWidth), ToPath); + using var path = renderService.VectorCache.GetOrCreate((feature.GenerationId, position, extent, rotation, lineWidth), ToPath); // If the Outline property is set and has a width greater than 0, draw the outline first. if (vectorStyle.Outline?.Width > 0) diff --git a/Mapsui.Rendering.Skia/SkiaStyles/PolygonRenderer.cs b/Mapsui.Rendering.Skia/SkiaStyles/PolygonRenderer.cs index 6de91c5d0f..c0801f6df0 100644 --- a/Mapsui.Rendering.Skia/SkiaStyles/PolygonRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaStyles/PolygonRenderer.cs @@ -20,7 +20,7 @@ public static void Draw(SKCanvas canvas, Viewport viewport, VectorStyle vectorSt Polygon polygon, float opacity, VectorCache vectorCache, int position) { // polygon - relevant for GeometryCollection children - SKPath ToPath((long featureId, int position, MRect extent, double rotation, float lineWidth) valueTuple) + SKPath ToPath((long featureGenerationId, int position, MRect extent, double rotation, float lineWidth) valueTuple) { var result = polygon.ToSkiaPath(viewport, viewport.ToSkiaRect(), valueTuple.lineWidth); return result; @@ -33,7 +33,7 @@ SKPath ToPath((long featureId, int position, MRect extent, double rotation, floa var rotation = viewport.Rotation; float lineWidth = (float)(vectorStyle.Outline?.Width ?? 1); - using var path = vectorCache.GetOrCreate((feature.Id, position, extent, rotation, lineWidth), ToPath); + using var path = vectorCache.GetOrCreate((feature.GenerationId, position, extent, rotation, lineWidth), ToPath); if (vectorStyle.Fill.IsVisible()) { using var fillPaint = vectorCache.GetOrCreate((vectorStyle.Fill, opacity, viewport.Rotation), CreateSkPaint); diff --git a/Mapsui.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs b/Mapsui.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs index 8c1d31f146..7ec597ba19 100644 --- a/Mapsui.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaStyles/SymbolStyleRenderer.cs @@ -74,7 +74,7 @@ private static SKPath CreatePath(SymbolType symbolType) TrianglePath(skPath, 0, 0, width); break; default: // Invalid value - throw new ArgumentException($"Unknown {nameof(SymbolType)} '{nameof(symbolType)}'"); + throw new ArgumentException($"Unknown {nameof(SymbolType)} '{symbolType}'"); } return skPath; diff --git a/Mapsui.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs b/Mapsui.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs index 8ebc6752b4..fe506581d6 100644 --- a/Mapsui.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs +++ b/Mapsui.Rendering.Skia/SkiaWidgets/ScaleBarWidgetRenderer.cs @@ -12,9 +12,7 @@ public class ScaleBarWidgetRenderer : ISkiaWidgetRenderer, IDisposable private readonly SKPaint _paintScaleBar = CreateScaleBarPaint(SKPaintStyle.Fill); private readonly SKPaint _paintScaleBarStroke = CreateScaleBarPaint(SKPaintStyle.Stroke); private readonly SKPaint _paintScaleText = CreateTextPaint(SKPaintStyle.Fill); - private readonly SKFont _paintScaleTextFont = CreateFont(); private readonly SKPaint _paintScaleTextStroke = CreateTextPaint(SKPaintStyle.Stroke); - private readonly SKFont _paintScaleTextStrokeFont = CreateFont(); public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, RenderService renderService, float layerOpacity) @@ -29,14 +27,12 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, RenderServi _paintScaleBarStroke.StrokeWidth = (float)(scaleBar.StrokeWidthHalo * scaleBar.Scale); _paintScaleText!.Color = scaleBar.TextColor.ToSkia(layerOpacity); _paintScaleText.StrokeWidth = (float)(scaleBar.StrokeWidth * scaleBar.Scale); - _paintScaleTextFont.Typeface = SKTypeface.FromFamilyName(scaleBar.Font?.FontFamily, - SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - _paintScaleTextFont.Size = (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale); _paintScaleTextStroke!.Color = scaleBar.Halo.ToSkia(layerOpacity); _paintScaleTextStroke.StrokeWidth = (float)(scaleBar.StrokeWidthHalo / 2 * scaleBar.Scale); - _paintScaleTextStrokeFont.Typeface = SKTypeface.FromFamilyName(scaleBar.Font?.FontFamily, - SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - _paintScaleTextStrokeFont.Size = (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale); + using var fontHolder = SkiaFontCache.GetOrCreate(scaleBar.Font, + (float)((scaleBar.Font?.Size ?? 10) * scaleBar.Scale), renderService, + boldOverride: true, edging: SKFontEdging.SubpixelAntialias); + var scaleTextFont = fontHolder.Instance; double scaleBarLength1; string? scaleBarText1; @@ -48,7 +44,7 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, RenderServi // Calc height of scale bar // Do this, because height of text changes sometimes (e.g. from 2 m to 1 m) - _paintScaleTextStrokeFont.MeasureText("9999 m", out var textSize, _paintScaleTextStroke); + scaleTextFont.MeasureText("9999 m", out var textSize, _paintScaleTextStroke); var scaleBarHeight = textSize.Height + (scaleBar.TickLength + scaleBar.StrokeWidthHalo * 0.5f + ScaleBarWidget.TextMargin) * scaleBar.Scale; @@ -91,28 +87,28 @@ public void Draw(SKCanvas canvas, Viewport viewport, IWidget widget, RenderServi var textSize2 = SKRect.Empty; scaleBarText1 ??= string.Empty; - _paintScaleTextStrokeFont.MeasureText(scaleBarText1, out var textSize1, _paintScaleTextStroke); + scaleTextFont.MeasureText(scaleBarText1, out var textSize1, _paintScaleTextStroke); if (scaleBar.ScaleBarMode == ScaleBarMode.Both && scaleBar.SecondaryUnitConverter != null) { // If there is SecondaryUnitConverter we need to calculate the size before passing it into GetScaleBarTextPositions scaleBarText2 ??= string.Empty; - _paintScaleTextStrokeFont.MeasureText(scaleBarText2, out textSize2, _paintScaleTextStroke); + scaleTextFont.MeasureText(scaleBarText2, out textSize2, _paintScaleTextStroke); } var (posX1, posY1, posX2, posY2) = scaleBar.GetScaleBarTextPositions(viewport, textSize1.ToMRect(), textSize2.ToMRect(), scaleBar.StrokeWidthHalo); // Now draw text - canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, _paintScaleTextStrokeFont, _paintScaleTextStroke); - canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, _paintScaleTextFont, _paintScaleText); + canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, scaleTextFont, _paintScaleTextStroke); + canvas.DrawText(scaleBarText1, (float)posX1, (float)(posY1 - textSize1.Top), SKTextAlign.Left, scaleTextFont, _paintScaleText); envelop = envelop?.Join(new MRect(posX1, posY1, posX1 + textSize1.Width, posY1 + textSize1.Height)); if (scaleBar.ScaleBarMode == ScaleBarMode.Both && scaleBar.SecondaryUnitConverter != null) { // Now draw second text - canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, _paintScaleTextStrokeFont, _paintScaleTextStroke); - canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, _paintScaleTextFont, _paintScaleText); + canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, scaleTextFont, _paintScaleTextStroke); + canvas.DrawText(scaleBarText2, (float)posX2, (float)(posY2 - textSize2.Top), SKTextAlign.Left, scaleTextFont, _paintScaleText); envelop = envelop?.Join(new MRect(posX2, posY2, posX2 + textSize2.Width, posY2 + textSize2.Height)); } @@ -145,14 +141,6 @@ private static SKPaint CreateTextPaint(SKPaintStyle style) }; } - private static SKFont CreateFont() - { - return new SKFont() - { - Edging = SKFontEdging.SubpixelAntialias, - }; - } - public void Dispose() { Dispose(true); @@ -166,9 +154,7 @@ protected virtual void Dispose(bool disposing) _paintScaleBar.Dispose(); _paintScaleBarStroke.Dispose(); _paintScaleText.Dispose(); - _paintScaleTextFont.Dispose(); _paintScaleTextStroke.Dispose(); - _paintScaleTextStrokeFont.Dispose(); } } } diff --git a/Mapsui.UI.Android/Mapsui.UI.Android.csproj b/Mapsui.UI.Android/Mapsui.UI.Android.csproj index 1be34d5810..e6624c53c7 100644 --- a/Mapsui.UI.Android/Mapsui.UI.Android.csproj +++ b/Mapsui.UI.Android/Mapsui.UI.Android.csproj @@ -4,7 +4,7 @@ <AndroidResgenFile>Resources\Resource.Designer.cs</AndroidResgenFile> <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> <RestorePackages>true</RestorePackages> - <TargetFrameworks>net8.0-android;net9.0-android</TargetFrameworks> + <TargetFrameworks>net9.0-android</TargetFrameworks> <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion> <ImplicitUsings>enable</ImplicitUsings> <PackageId>Mapsui.Android</PackageId> diff --git a/Mapsui.UI.Avalonia/MapControl.cs b/Mapsui.UI.Avalonia/MapControl.cs index 4398782ceb..0beb16caa5 100644 --- a/Mapsui.UI.Avalonia/MapControl.cs +++ b/Mapsui.UI.Avalonia/MapControl.cs @@ -24,7 +24,8 @@ public partial class MapControl : UserControl, IMapControl, IDisposable { private MapsuiCustomDrawOperation? _drawOperation; private double _mouseWheelPos = 0.0; - private readonly ConcurrentDictionary<long, ScreenPosition> _positions = new(); + private readonly ConcurrentDictionary<long, PointerRecording> _positions = new(); + private long _nextPressOrder; private bool _shiftPressed; private readonly ManipulationTracker _manipulationTracker = new(); @@ -106,12 +107,13 @@ private void MapControl_PointerPressed(object? sender, PointerPressedEventArgs e return; var position = e.GetPosition(this).ToScreenPosition(); - _positions[e.Pointer.Id] = position; + _positions[e.Pointer.Id] = new PointerRecording(position, _nextPressOrder++); + var positions = GetScreenPositions(); - if (_positions.Count == 1) // Not sure if this check is necessary. - _manipulationTracker.Restart(_positions.Values.ToArray()); + if (positions.Length == 1) // Not sure if this check is necessary. + _manipulationTracker.Restart(positions); - if (OnPointerPressed(_positions.Values.ToArray())) + if (OnPointerPressed(positions)) return; e.Pointer.Capture(this); @@ -129,10 +131,12 @@ private void MapControl_PointerMoved(object? sender, PointerEventArgs e) } else { - _positions[e.Pointer.Id] = position; - if (OnPointerMoved(_positions.Values.ToArray(), isHovering)) + var pressOrder = _positions.TryGetValue(e.Pointer.Id, out var previous) ? previous.PressOrder : _nextPressOrder++; + _positions[e.Pointer.Id] = new PointerRecording(position, pressOrder); + var positions = GetScreenPositions(); + if (OnPointerMoved(positions, isHovering)) return; - _manipulationTracker.Manipulate(_positions.Values.ToArray(), Map.Navigator.Manipulate); + _manipulationTracker.Manipulate(positions, Map.Navigator.Manipulate); } } @@ -146,6 +150,14 @@ private void MapControl_PointerReleased(object? sender, PointerReleasedEventArgs e.Pointer.Capture(null); } + private ScreenPosition[] GetScreenPositions() + { + // Sorted explicitly because the enumeration order of a ConcurrentDictionary is not defined. The + // ManipulationTracker derives the rotation from the first two positions, so if two fingers swapped places + // between events the map would rotate 180 degrees. This happened on iOS with UIEvent.AllTouches, see #2738. + return _positions.Values.OrderBy(p => p.PressOrder).Select(p => p.ScreenPosition).ToArray(); + } + private bool IsHovering(PointerEventArgs e) { return e.Pointer.Type == PointerType.Mouse && !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed; @@ -283,4 +295,6 @@ public virtual void Dispose() } private bool GetShiftPressed() => _shiftPressed; + + private record struct PointerRecording(ScreenPosition ScreenPosition, long PressOrder); } diff --git a/Mapsui.UI.Blazor/Mapsui.UI.Blazor.csproj b/Mapsui.UI.Blazor/Mapsui.UI.Blazor.csproj index 77954e1345..0e6333392a 100644 --- a/Mapsui.UI.Blazor/Mapsui.UI.Blazor.csproj +++ b/Mapsui.UI.Blazor/Mapsui.UI.Blazor.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk.Razor"> <Import Project="Mapsui.Blazor.targets" /> <PropertyGroup> - <TargetFrameworks>net8.0;net9.0</TargetFrameworks> + <TargetFrameworks>net9.0</TargetFrameworks> <ImplicitUsings>enable</ImplicitUsings> <EnableNETAnalyzers>True</EnableNETAnalyzers> <DefineConstants>__BLAZOR__</DefineConstants> diff --git a/Mapsui.UI.Maui/AssemblyInfo.cs b/Mapsui.UI.Maui/AssemblyInfo.cs new file mode 100644 index 0000000000..c915f808b6 --- /dev/null +++ b/Mapsui.UI.Maui/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Mapsui.UI.Maui.Tests")] diff --git a/Mapsui.UI.Maui/Drawing/GpuMapDrawingView.cs b/Mapsui.UI.Maui/Drawing/GpuMapDrawingView.cs new file mode 100644 index 0000000000..51894d2bef --- /dev/null +++ b/Mapsui.UI.Maui/Drawing/GpuMapDrawingView.cs @@ -0,0 +1,30 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; +using SkiaSharp.Views.Maui; +using SkiaSharp.Views.Maui.Controls; + +namespace Mapsui.UI.Maui.Drawing; + +internal sealed class GpuMapDrawingView : MapDrawingView +{ + private SKGLView GlView => (SKGLView)View; + + public GpuMapDrawingView() : base(new SKGLView { HasRenderLoop = false, EnableTouchEvents = true }) + { + GlView.PaintSurface += OnPaintSurface; + GlView.Touch += OnTouch; + } + + public override SKSize CanvasSize => GlView.CanvasSize; + public override void Invalidate() => GlView.InvalidateSurface(); + private void OnPaintSurface(object? sender, SKPaintGLSurfaceEventArgs args) => OnPaint(args.Surface.Canvas, GlView.GRContext); + + protected override void Detach() + { + GlView.PaintSurface -= OnPaintSurface; + GlView.Touch -= OnTouch; + } +} diff --git a/Mapsui.UI.Maui/Drawing/IMapDrawingView.cs b/Mapsui.UI.Maui/Drawing/IMapDrawingView.cs new file mode 100644 index 0000000000..248e5bfd53 --- /dev/null +++ b/Mapsui.UI.Maui/Drawing/IMapDrawingView.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using SkiaSharp; +using SkiaSharp.Views.Maui; + +namespace Mapsui.UI.Maui.Drawing; + +internal interface IMapDrawingView : IDisposable +{ + View View { get; } + SKSize CanvasSize { get; } + event Action<SKCanvas, object?>? Paint; + event EventHandler<SKTouchEventArgs>? Touch; + event EventHandler? SizeChanged; + void Connect(IMauiContext context); + void Invalidate(); +} diff --git a/Mapsui.UI.Maui/Drawing/MapDrawingView.cs b/Mapsui.UI.Maui/Drawing/MapDrawingView.cs new file mode 100644 index 0000000000..ba7a9214e9 --- /dev/null +++ b/Mapsui.UI.Maui/Drawing/MapDrawingView.cs @@ -0,0 +1,70 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using SkiaSharp; +using SkiaSharp.Views.Maui; + +namespace Mapsui.UI.Maui.Drawing; + +internal abstract class MapDrawingView : IMapDrawingView +{ + private readonly object _callbackLock = new(); + private bool _disposed; + + protected MapDrawingView(View view) + { + View = view; + View.SizeChanged += OnSizeChanged; + } + + public View View { get; } + public abstract SKSize CanvasSize { get; } + public event Action<SKCanvas, object?>? Paint; + public event EventHandler<SKTouchEventArgs>? Touch; + public event EventHandler? SizeChanged; + public virtual void Connect(IMauiContext context) { } + public abstract void Invalidate(); + protected abstract void Detach(); + + protected void OnPaint(SKCanvas canvas, object? context) + { + lock (_callbackLock) + if (!_disposed) + Paint?.Invoke(canvas, context); + } + + protected void OnTouch(object? sender, SKTouchEventArgs args) + { + lock (_callbackLock) + if (!_disposed) + Touch?.Invoke(this, args); + } + + private void OnSizeChanged(object? sender, EventArgs args) + { + lock (_callbackLock) + if (!_disposed) + SizeChanged?.Invoke(this, args); + } + + public virtual void Dispose() + { + // Wait for borrowed paint resources to leave MapControl before its map is disposed. + lock (_callbackLock) + { + if (_disposed) + return; + _disposed = true; + Paint = null; + Touch = null; + SizeChanged = null; + } + View.SizeChanged -= OnSizeChanged; + Detach(); + View.Handler?.DisconnectHandler(); + } +} diff --git a/Mapsui.UI.Maui/Drawing/MapDrawingViewFactory.cs b/Mapsui.UI.Maui/Drawing/MapDrawingViewFactory.cs new file mode 100644 index 0000000000..0a62302c81 --- /dev/null +++ b/Mapsui.UI.Maui/Drawing/MapDrawingViewFactory.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Mapsui.UI.Maui.Drawing; + +internal static class MapDrawingViewFactory +{ + public static (RenderBackend Backend, IMapDrawingView View) Create(RenderBackend backend) => backend switch + { + RenderBackend.Software => (RenderBackend.Software, new SoftwareMapDrawingView()), + RenderBackend.Gpu => (RenderBackend.Gpu, new GpuMapDrawingView()), +#if ANDROID + RenderBackend.AndroidSurfaceView => (RenderBackend.AndroidSurfaceView, new AndroidSurfaceMapDrawingView()), +#else + RenderBackend.AndroidSurfaceView => (RenderBackend.Gpu, new GpuMapDrawingView()), +#endif + _ => throw new ArgumentOutOfRangeException(nameof(backend), backend, "Unknown MAUI render backend.") + }; +} diff --git a/Mapsui.UI.Maui/Drawing/SoftwareMapDrawingView.cs b/Mapsui.UI.Maui/Drawing/SoftwareMapDrawingView.cs new file mode 100644 index 0000000000..b2b871e25c --- /dev/null +++ b/Mapsui.UI.Maui/Drawing/SoftwareMapDrawingView.cs @@ -0,0 +1,30 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; +using SkiaSharp.Views.Maui; +using SkiaSharp.Views.Maui.Controls; + +namespace Mapsui.UI.Maui.Drawing; + +internal sealed class SoftwareMapDrawingView : MapDrawingView +{ + private SKCanvasView CanvasView => (SKCanvasView)View; + + public SoftwareMapDrawingView() : base(new SKCanvasView { EnableTouchEvents = true }) + { + CanvasView.PaintSurface += OnPaintSurface; + CanvasView.Touch += OnTouch; + } + + public override SKSize CanvasSize => CanvasView.CanvasSize; + public override void Invalidate() => CanvasView.InvalidateSurface(); + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs args) => OnPaint(args.Surface.Canvas, null); + + protected override void Detach() + { + CanvasView.PaintSurface -= OnPaintSurface; + CanvasView.Touch -= OnTouch; + } +} diff --git a/Mapsui.UI.Maui/MapControl.cs b/Mapsui.UI.Maui/MapControl.cs index 2993a08d4c..bdbba3e13f 100644 --- a/Mapsui.UI.Maui/MapControl.cs +++ b/Mapsui.UI.Maui/MapControl.cs @@ -3,7 +3,7 @@ using Mapsui.Manipulations; using SkiaSharp; using SkiaSharp.Views.Maui; -using SkiaSharp.Views.Maui.Controls; +using Mapsui.UI.Maui.Drawing; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -19,11 +19,31 @@ namespace Mapsui.UI.Maui; /// </summary> public partial class MapControl : ContentView, IMapControl, IDisposable { - public static bool UseGPU { get; set; } = true; + /// <summary>Default backend for new parameterless controls. Read once during construction.</summary> + public static RenderBackend Backend { get; set; } = RenderBackend.Gpu; - private readonly SKGLView? _glView; - private readonly SKCanvasView? _canvasView; + /// <summary> + /// Compatibility setting for <see cref="Backend"/>. Setting true selects <see cref="RenderBackend.Gpu"/>; + /// setting false selects <see cref="RenderBackend.Software"/>. Existing controls are unaffected. + /// </summary> + public static bool UseGPU + { + get => Backend != RenderBackend.Software; + set => Backend = value ? RenderBackend.Gpu : RenderBackend.Software; + } + + /// <summary> + /// The backend selected for this instance. AndroidSurfaceView maps to Gpu on other platforms. + /// Changing the static default does not change this control or its lifecycle behavior. + /// Native initialization errors are reported as errors, without substituting a different backend. + /// </summary> + public RenderBackend ActiveBackend { get; } + + private readonly IMapDrawingView _drawingView; + private readonly Action _invalidateDrawingView; + private bool _disposed; private readonly ConcurrentDictionary<long, PointerRecording> _positions = new(); + private long _nextPressOrder; private static List<WeakReference<MapControl>>? _listeners; private readonly ManipulationTracker _manipulationTracker = new(); @@ -35,51 +55,43 @@ public partial class MapControl : ContentView, IMapControl, IDisposable /// period of dangling ghost touches. You might want to tweak this value to your needs. /// </summary> public TimeSpan IsStaleTimeSpan { get; set; } = TimeSpan.FromMilliseconds(500); // Even with a value of 100 I never see removal of a valid event, so I assume 500 is save. And perhaps it could be set even lower because if a valid event is removed sometimes I don't notice any change in the UI. - public MapControl() - { - View view; + /// <summary>Constructs a control using the current <see cref="Backend"/> default (Gpu initially).</summary> + public MapControl() : this(Backend) { } + /// <summary>Constructs a control with a fixed backend, independently of the static default.</summary> + /// <param name="backend">The backend to use. AndroidSurfaceView maps to Gpu on non-Android platforms.</param> + public MapControl(RenderBackend backend) + { + (ActiveBackend, _drawingView) = MapDrawingViewFactory.Create(backend); + _invalidateDrawingView = InvalidateDrawingView; BackgroundColor = KnownColor.White; InitTouchesReset(this); - - if (UseGPU) - { - // Use GPU backend - _glView = new SKGLView - { - HasRenderLoop = false, - EnableTouchEvents = true, - }; - // Events - _glView.Touch += OnTouch; - _glView.PaintSurface += OnGLPaintSurface; - view = _glView; - } - else - { - // Use CPU backend - _canvasView = new SKCanvasView - { - EnableTouchEvents = true, - }; - // Events - _canvasView.Touch += OnTouch; - _canvasView.PaintSurface += OnPaintSurface; - view = _canvasView; - } - view.SizeChanged += View_SizeChanged; - Content = view; + _drawingView.Touch += OnTouch; + _drawingView.Paint += OnPaint; + _drawingView.SizeChanged += View_SizeChanged; + Content = _drawingView.View; SharedConstructor(); Mapsui.Utilities.PlatformUtilities.SetOpenInBrowserFunc(OpenInBrowserStatic); } + + protected override void OnHandlerChanging(HandlerChangingEventArgs args) + { + // Connect the dedicated host before the parent's handler maps Content to a native view. + if (!_disposed && args.NewHandler?.MauiContext is { } context) + _drawingView.Connect(context); + base.OnHandlerChanging(args); + } + public void InvalidateCanvas() { - if (_glView is SKGLView glView) - RunOnUIThread(glView.InvalidateSurface); - else if (_canvasView is SKCanvasView canvasView) - RunOnUIThread(canvasView.InvalidateSurface); - else - throw new InvalidOperationException("Neither SKGLView nor SKCanvasView is initialized."); + if (!_disposed) + RunOnUIThread(_invalidateDrawingView); + } + + private void InvalidateDrawingView() + { + if (!_disposed) + _drawingView.Invalidate(); } private static bool IsMaui9() @@ -167,11 +179,12 @@ private void OnTouch(object? sender, SKTouchEventArgs e) if (e.ActionType == SKTouchAction.Pressed) { - _positions[e.Id] = new PointerRecording(position, Environment.TickCount64); - if (_positions.Count == 1) // Not sure if this check is necessary. - _manipulationTracker.Restart(_positions.Values.Select(p => p.ScreenPosition).ToArray()); + _positions[e.Id] = new PointerRecording(position, Environment.TickCount64, _nextPressOrder++); + var positions = GetScreenPositions(); + if (positions.Length == 1) // Not sure if this check is necessary. + _manipulationTracker.Restart(positions); - if (OnPointerPressed(_positions.Values.Select(p => p.ScreenPosition).ToArray())) + if (OnPointerPressed(positions)) return; } else if (e.ActionType == SKTouchAction.Moved) @@ -186,12 +199,14 @@ private void OnTouch(object? sender, SKTouchEventArgs e) } else { - _positions[e.Id] = new PointerRecording(position, Environment.TickCount64); + var pressOrder = _positions.TryGetValue(e.Id, out var previous) ? previous.PressOrder : _nextPressOrder++; + _positions[e.Id] = new PointerRecording(position, Environment.TickCount64, pressOrder); + var positions = GetScreenPositions(); - if (OnPointerMoved(_positions.Values.Select(p => p.ScreenPosition).ToArray(), isHovering)) + if (OnPointerMoved(positions, isHovering)) return; - _manipulationTracker.Manipulate(_positions.Values.Select(p => p.ScreenPosition).ToArray(), Map.Navigator.Manipulate); + _manipulationTracker.Manipulate(positions, Map.Navigator.Manipulate); } } else if (e.ActionType == SKTouchAction.Released) @@ -202,9 +217,6 @@ private void OnTouch(object? sender, SKTouchEventArgs e) } else if (e.ActionType == SKTouchAction.Cancelled) { - if (!e.InContact) - return; - _positions.Clear(); Refresh(); } @@ -223,6 +235,14 @@ private void OnTouch(object? sender, SKTouchEventArgs e) }); } + private ScreenPosition[] GetScreenPositions() + { + // Sorted explicitly because the enumeration order of a ConcurrentDictionary is not defined. The + // ManipulationTracker derives the rotation from the first two positions, so if two fingers swapped places + // between events the map would rotate 180 degrees. This happened on iOS with UIEvent.AllTouches, see #2738. + return _positions.Values.OrderBy(p => p.PressOrder).Select(p => p.ScreenPosition).ToArray(); + } + private static void RemoveStale(ConcurrentDictionary<long, PointerRecording> positions, double totalMilliseconds) { var currentTickCount = Environment.TickCount64; @@ -235,33 +255,28 @@ private static void RemoveStale(ConcurrentDictionary<long, PointerRecording> pos } } - private void OnGLPaintSurface(object? sender, SKPaintGLSurfaceEventArgs args) + private void OnPaint(SKCanvas canvas, object? context) { - if (_glView?.GRContext is null) + if (_disposed) + return; + if (ActiveBackend != RenderBackend.Software && context is null) { - // Could this be null before Home is called? If so we should change the logic. - Logger.Log(LogLevel.Warning, "Refresh can not be called because GRContext is null"); - _renderController?.Render(args.Surface.Canvas, null); // unblock the invalidation loop + _renderController?.Render(canvas, null); // Complete invalidation even before a GPU context exists. return; } - // Keep the GPU context current so the renderer can create GPU-backed surfaces. - Map.RenderService.GpuContext = _glView.GRContext; - - // Called on UI thread - _renderController?.Render(args.Surface.Canvas, GetPixelDensity()); + // A replacement GL context invalidates the persistent surface owned by RenderService. + if (!ReferenceEquals(Map.RenderService.GpuContext, context)) + _renderController?.RefreshGraphics(); + Map.RenderService.GpuContext = context; + _renderController?.Render(canvas, GetPixelDensity()); } - private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs args) - { - // Called on UI thread - _renderController?.Render(args.Surface.Canvas, GetPixelDensity()); - } private static void OpenInBrowserStatic(string url) { Catch.TaskRun(() => _ = Launcher.OpenAsync(new Uri(url))); } - private ScreenPosition GetScreenPosition(SKPoint point, float pixelDensity) => + internal static ScreenPosition GetScreenPosition(SKPoint point, float pixelDensity) => new(point.X / pixelDensity, point.Y / pixelDensity); private void OnZoomInOrOut(int mouseWheelDelta, ScreenPosition currentMousePosition) @@ -289,30 +304,6 @@ protected void RunOnUIThread(Action action) Dispatcher.Dispatch(() => Catch.Exceptions(action)); } - private void DetachSkiaViewHandlers() - { - if (_glView != null) - { - _glView.Touch -= OnTouch; - _glView.PaintSurface -= OnGLPaintSurface; - _glView.SizeChanged -= View_SizeChanged; - _glView.Handler?.DisconnectHandler(); - } - - if (_canvasView != null) - { - _canvasView.Touch -= OnTouch; - _canvasView.PaintSurface -= OnPaintSurface; - _canvasView.SizeChanged -= View_SizeChanged; - _canvasView.Handler?.DisconnectHandler(); - } - - if (Content != null) - { - Content.SizeChanged -= View_SizeChanged; - } - } - public void Dispose() { Dispose(true); @@ -320,15 +311,20 @@ public void Dispose() } protected virtual void Dispose(bool disposing) { - if (disposing) + if (disposing && !_disposed) { + _disposed = true; var weakReference = _listeners?.FirstOrDefault(f => f.TryGetTarget(out var control) && control == this); if (weakReference != null) { _listeners?.Remove(weakReference); } - DetachSkiaViewHandlers(); + _drawingView.Touch -= OnTouch; + _drawingView.Paint -= OnPaint; + _drawingView.SizeChanged -= View_SizeChanged; + _drawingView.Dispose(); + ClearTouchState(); Map?.Dispose(); #if ANDROID @@ -366,12 +362,12 @@ protected virtual void Dispose(bool disposing) private double GetCanvasWidth() { - return _glView?.CanvasSize.Width ?? _canvasView!.CanvasSize.Width; + return _drawingView.CanvasSize.Width; } private static bool GetShiftPressed() => false; // Work in progress: https://github.com/dotnet/maui/issues/16202 - private record struct PointerRecording(ScreenPosition ScreenPosition, long Timestamp) + private record struct PointerRecording(ScreenPosition ScreenPosition, long Timestamp, long PressOrder) { } diff --git a/Mapsui.UI.Maui/Platforms/Android/AndroidMapSurfaceView.cs b/Mapsui.UI.Maui/Platforms/Android/AndroidMapSurfaceView.cs new file mode 100644 index 0000000000..5a0e5bb6f7 --- /dev/null +++ b/Mapsui.UI.Maui/Platforms/Android/AndroidMapSurfaceView.cs @@ -0,0 +1,147 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Android.Content; +using Android.Opengl; +using Android.Views; +using Javax.Microedition.Khronos.Opengles; +using SkiaSharp; +using SkiaSharp.Views.Android; +using EGLConfig = Javax.Microedition.Khronos.Egl.EGLConfig; + +namespace Mapsui.UI.Maui.Drawing; + +// SKGLSurfaceView's renderer does not reset its Skia context in OnSurfaceCreated. +// Own the renderer's lifetime here so an Android EGL context loss cannot reuse stale GPU resources. +internal sealed class AndroidMapSurfaceView : GLSurfaceView +{ + private readonly ContextRenderer _renderer; + private bool _suspended; + private bool _paused; + private bool _stopped; + + public AndroidMapSurfaceView(Context context) : base(context) + { + SetEGLContextClientVersion(2); + SetEGLConfigChooser(8, 8, 8, 8, 0, 8); + _renderer = new ContextRenderer(this); + SetRenderer(_renderer); + RenderMode = Rendermode.WhenDirty; + } + + public GRContext? GRContext => _renderer.Context; + public event EventHandler<SKPaintGLSurfaceEventArgs>? PaintSurface; + + public void SetSuspended(bool suspended) + { + _suspended = suspended; + UpdateRunningState(); + } + + protected override void OnAttachedToWindow() + { + base.OnAttachedToWindow(); + UpdateRunningState(); + RequestRender(); + } + + protected override void OnDetachedFromWindow() + { + Pause(); + base.OnDetachedFromWindow(); + } + + protected override void OnWindowVisibilityChanged(ViewStates visibility) + { + base.OnWindowVisibilityChanged(visibility); + // Android can call this from its base constructor, before SetRenderer. + if (_renderer != null) + UpdateRunningState(); + } + + protected override void OnVisibilityChanged(global::Android.Views.View changedView, ViewStates visibility) + { + base.OnVisibilityChanged(changedView, visibility); + if (_renderer != null) + UpdateRunningState(); + } + + private void UpdateRunningState() + { + if (_stopped) + return; + if (_suspended || !IsAttachedToWindow || !IsShown || WindowVisibility != ViewStates.Visible) + Pause(); + else if (_paused) + { + _paused = false; + OnResume(); + RequestRender(); + } + } + + private void Pause() + { + if (_paused || _stopped) + return; + _paused = true; + OnPause(); + } + + public void Stop() + { + if (_stopped) + return; + Pause(); // Waits for the entire GL frame, including Skia's flush, to finish. + _stopped = true; + PaintSurface = null; + _renderer.Dispose(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + Stop(); + base.Dispose(disposing); + } + + private sealed class ContextRenderer(AndroidMapSurfaceView owner) : Java.Lang.Object, IRenderer + { + private PaintRenderer? _renderer; + public GRContext? Context => _renderer?.GRContext; + + public void OnSurfaceCreated(IGL10? gl, EGLConfig? config) + { + ReleaseContext(); +#pragma warning disable IDISP003 // ReleaseContext abandons and disposes the previous renderer before replacement. + _renderer = new PaintRenderer(owner); +#pragma warning restore IDISP003 + _renderer.OnSurfaceCreated(gl, config); + } + + public void OnSurfaceChanged(IGL10? gl, int width, int height) => _renderer?.OnSurfaceChanged(gl, width, height); + public void OnDrawFrame(IGL10? gl) => _renderer?.OnDrawFrame(gl); + + private void ReleaseContext() + { + // The old EGL context may already be gone, or cleanup may be on the UI thread. + _renderer?.GRContext?.AbandonContext(); + _renderer?.Dispose(); + _renderer = null; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + ReleaseContext(); + base.Dispose(disposing); + } + } + + private sealed class PaintRenderer(AndroidMapSurfaceView owner) : SKGLSurfaceViewRenderer + { + protected override void OnPaintSurface(SKPaintGLSurfaceEventArgs args) => owner.PaintSurface?.Invoke(owner, args); + } +} diff --git a/Mapsui.UI.Maui/Platforms/Android/AndroidSurfaceMapDrawingView.cs b/Mapsui.UI.Maui/Platforms/Android/AndroidSurfaceMapDrawingView.cs new file mode 100644 index 0000000000..e3566b3bbf --- /dev/null +++ b/Mapsui.UI.Maui/Platforms/Android/AndroidSurfaceMapDrawingView.cs @@ -0,0 +1,186 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using Android.Views; +using Microsoft.Maui; +using Microsoft.Maui.Handlers; +using SkiaSharp; +using SkiaSharp.Views.Maui; +using NativePaintEventArgs = SkiaSharp.Views.Android.SKPaintGLSurfaceEventArgs; +using View = Microsoft.Maui.Controls.View; +using Window = Microsoft.Maui.Controls.Window; + +namespace Mapsui.UI.Maui.Drawing; + +internal sealed class AndroidSurfaceMapDrawingView : MapDrawingView +{ + private int _canvasWidth; + private int _canvasHeight; + private SurfaceMapView SurfaceView => (SurfaceMapView)View; + + public AndroidSurfaceMapDrawingView() : base(new SurfaceMapView()) + { + SurfaceView.Paint = PaintSurface; + SurfaceView.Touch = args => OnTouch(this, args); + } + + public override SKSize CanvasSize => new(_canvasWidth, _canvasHeight); + + public override void Connect(IMauiContext context) + { + if (SurfaceView.Handler is SurfaceMapViewHandler { Connected: true } handler && handler.MauiContext == context) + return; + + var newHandler = new SurfaceMapViewHandler(); + newHandler.SetMauiContext(context); + SurfaceView.Handler = newHandler; + } + + public override void Invalidate() => (SurfaceView.Handler as SurfaceMapViewHandler)?.RequestRender(); + + private void PaintSurface(NativePaintEventArgs args, object? context) + { + _canvasWidth = args.Info.Width; + _canvasHeight = args.Info.Height; + OnPaint(args.Surface.Canvas, context); + } + + protected override void Detach() + { + SurfaceView.Paint = null; + SurfaceView.Touch = null; + SurfaceView.DetachWindow(); + } +} + +internal sealed class SurfaceMapView : View +{ + private Window? _window; + public Action<NativePaintEventArgs, object?>? Paint { get; set; } + public Action<SKTouchEventArgs>? Touch { get; set; } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == nameof(Window)) + { + DetachWindow(); + _window = Window; + if (_window != null) + { + _window.Stopped += OnStopped; + _window.Resumed += OnResumed; + } + } + } + + private void OnStopped(object? sender, EventArgs args) => (Handler as SurfaceMapViewHandler)?.SetSuspended(true); + private void OnResumed(object? sender, EventArgs args) => (Handler as SurfaceMapViewHandler)?.SetSuspended(false); + + public void DetachWindow() + { + if (_window == null) + return; + _window.Stopped -= OnStopped; + _window.Resumed -= OnResumed; + _window = null; + } +} + +internal sealed class SurfaceMapViewHandler : ViewHandler<SurfaceMapView, AndroidMapSurfaceView> +{ + public bool Connected { get; private set; } + public SurfaceMapViewHandler() : base(ViewMapper) { } + protected override AndroidMapSurfaceView CreatePlatformView() => new(Context); + + protected override void ConnectHandler(AndroidMapSurfaceView platformView) + { + base.ConnectHandler(platformView); + Connected = true; + platformView.PaintSurface += OnPaintSurface; + platformView.Touch += OnTouch; + platformView.GenericMotion += OnGenericMotion; + } + + protected override void DisconnectHandler(AndroidMapSurfaceView platformView) + { + Connected = false; + platformView.PaintSurface -= OnPaintSurface; + platformView.Touch -= OnTouch; + platformView.GenericMotion -= OnGenericMotion; + platformView.Stop(); + base.DisconnectHandler(platformView); +#pragma warning disable IDISP007 // This handler creates and owns the native view in CreatePlatformView. + platformView.Dispose(); +#pragma warning restore IDISP007 + } + + public void RequestRender() + { + if (Connected) + PlatformView.RequestRender(); + } + + public void SetSuspended(bool suspended) + { + if (Connected) + PlatformView.SetSuspended(suspended); + } + + private void OnPaintSurface(object? sender, NativePaintEventArgs args) + { + if (Connected) + VirtualView?.Paint?.Invoke(args, PlatformView.GRContext); + } + + private void OnTouch(object? sender, global::Android.Views.View.TouchEventArgs args) => args.Handled = DispatchMotion(args.Event); + private void OnGenericMotion(object? sender, global::Android.Views.View.GenericMotionEventArgs args) => args.Handled = DispatchMotion(args.Event); + + private bool DispatchMotion(MotionEvent? motion) + { + if (!Connected || VirtualView?.Touch is not { } touch || motion is null) + return false; + + var action = motion.ActionMasked switch + { + MotionEventActions.Down or MotionEventActions.PointerDown => SKTouchAction.Pressed, + MotionEventActions.Move or MotionEventActions.HoverMove => SKTouchAction.Moved, + MotionEventActions.Up or MotionEventActions.PointerUp => SKTouchAction.Released, + MotionEventActions.Cancel => SKTouchAction.Cancelled, + MotionEventActions.HoverEnter => SKTouchAction.Entered, + MotionEventActions.HoverExit => SKTouchAction.Exited, + MotionEventActions.Scroll => SKTouchAction.WheelChanged, + _ => (SKTouchAction?)null + }; + if (action is null) + return false; + + var handled = false; + var first = motion.ActionMasked == MotionEventActions.Move ? 0 : motion.ActionIndex; + var end = motion.ActionMasked == MotionEventActions.Move ? motion.PointerCount : first + 1; + for (var pointer = first; pointer < end; pointer++) + { + var tool = motion.GetToolType(pointer); + var device = tool switch + { + MotionEventToolType.Mouse => SKTouchDeviceType.Mouse, + MotionEventToolType.Stylus or MotionEventToolType.Eraser => SKTouchDeviceType.Pen, + _ => SKTouchDeviceType.Touch + }; + var secondary = (motion.ButtonState & MotionEventButtonState.Secondary) != 0 + || (OperatingSystem.IsAndroidVersionAtLeast(23) && (motion.ButtonState & MotionEventButtonState.StylusSecondary) != 0); + var button = tool == MotionEventToolType.Eraser ? SKMouseButton.Middle : secondary + ? SKMouseButton.Right : SKMouseButton.Left; + var inContact = motion.ActionMasked is MotionEventActions.Down or MotionEventActions.PointerDown or MotionEventActions.Move; + var wheelDelta = action == SKTouchAction.WheelChanged ? (int)(motion.GetAxisValue(Axis.Vscroll) * 120) : 0; + // Keep native pixels and pointer IDs intact. MapControl applies density once for every backend. + var args = new SKTouchEventArgs(motion.GetPointerId(pointer), action.Value, button, device, + new SKPoint(motion.GetX(pointer), motion.GetY(pointer)), inContact, wheelDelta, motion.GetPressure(pointer)); + touch(args); + handled |= args.Handled; + } + return handled; + } +} diff --git a/Mapsui.UI.Maui/Platforms/Android/MapControl.cs b/Mapsui.UI.Maui/Platforms/Android/MapControl.cs index 98151039c7..3933cad725 100644 --- a/Mapsui.UI.Maui/Platforms/Android/MapControl.cs +++ b/Mapsui.UI.Maui/Platforms/Android/MapControl.cs @@ -57,7 +57,7 @@ private void Element_ParentChanged(object? sender, EventArgs e) private void AttachToOnAppearing() { - if (UseGPU && Parent != null) + if (ActiveBackend == RenderBackend.Gpu && Parent != null) { _page = GetPage(Parent); if (_page != null) @@ -119,7 +119,9 @@ private static void LoadReflectionFields(SkiaSharp.Views.Android.GLTextureView g /// </summary> public void FixInvisible() { - var handler = _glView?.Handler as SKGLViewHandler; + if (_disposed || ActiveBackend != RenderBackend.Gpu) + return; + var handler = _drawingView.View.Handler as SKGLViewHandler; if (handler is not null) { SkiaSharp.Views.Android.GLTextureView glTextureView = handler.PlatformView; diff --git a/Mapsui.UI.Maui/RenderBackend.cs b/Mapsui.UI.Maui/RenderBackend.cs new file mode 100644 index 0000000000..4eeda787dc --- /dev/null +++ b/Mapsui.UI.Maui/RenderBackend.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Mapsui.UI.Maui; + +/// <summary>Selects the drawing backend and, on Android, its native host view.</summary> +public enum RenderBackend +{ + /// <summary>CPU rasterization using an SKCanvasView on every platform.</summary> + Software, + /// <summary>The default GPU view for the platform; a TextureView on Android.</summary> + Gpu, + /// <summary> + /// GPU drawing in an Android SurfaceView. On other platforms this maps to + /// <see cref="Gpu"/>, which is also reported by <see cref="MapControl.ActiveBackend"/>. + /// </summary> + AndroidSurfaceView +} diff --git a/Mapsui.UI.Maui8/Mapsui.UI.Maui8.csproj b/Mapsui.UI.Maui8/Mapsui.UI.Maui8.csproj index 395885df31..e1d07faed5 100644 --- a/Mapsui.UI.Maui8/Mapsui.UI.Maui8.csproj +++ b/Mapsui.UI.Maui8/Mapsui.UI.Maui8.csproj @@ -36,17 +36,29 @@ <Compile Include="..\Mapsui.UI.Maui\Extensions\TextAlignmentExtensions.cs" Link="Extensions\TextAlignmentExtensions.cs" /> <Compile Include="..\Mapsui.UI.Maui\GlobalSuppressions.cs" Link="GlobalSuppressions.cs" /> <Compile Include="..\Mapsui.UI.Maui\MapControl.cs" Link="MapControl.cs" /> + <Compile Include="..\Mapsui.UI.Maui\RenderBackend.cs" Link="RenderBackend.cs" /> + <Compile Include="..\Mapsui.UI.Maui\Drawing\*.cs" Link="Drawing\%(Filename)%(Extension)" /> <Compile Include="..\Mapsui.UI.Maui\Utils\KnownColor.cs" Link="Utils\KnownColor.cs" /> </ItemGroup> <!-- Android --> <ItemGroup Condition="$(TargetFramework.Contains('-android'))"> <Compile Include="..\Mapsui.UI.Maui\Platforms\Android\MapControl.cs" Link="Platforms\Android\MapControl.cs" /> + <Compile Include="..\Mapsui.UI.Maui\Platforms\Android\AndroidMapSurfaceView.cs" Link="Platforms\Android\AndroidMapSurfaceView.cs" /> + <Compile Include="..\Mapsui.UI.Maui\Platforms\Android\AndroidSurfaceMapDrawingView.cs" Link="Platforms\Android\AndroidSurfaceMapDrawingView.cs" /> </ItemGroup> <ItemGroup> - <PackageReference Include="SkiaSharp" /> - <PackageReference Include="SkiaSharp.Views.Maui.Controls" /> + <!-- SkiaSharp dropped its net8.0 assets in 3.119.4, so this net8.0-only head stays on 3.119.2, + the last version that still ships them. Both are assembly version 3.119.0.0, so the shared + Mapsui.Rendering.Skia binary (built against the central version) loads against either. --> + <PackageReference Include="SkiaSharp" VersionOverride="$(Maui8SkiaSharpVersion)" /> + <PackageReference Include="SkiaSharp.Views.Maui.Controls" VersionOverride="$(Maui8SkiaSharpVersion)" /> + <!-- Pulled in transitively by SkiaSharp.Views.Maui.Controls. Central package transitive pinning + would otherwise force it to the central version, which has no net8.0 assets. Only the + android head actually resolves a platform asset for it. --> + <PackageReference Include="SkiaSharp.Views" VersionOverride="$(Maui8SkiaSharpVersion)" + Condition="$(TargetFramework.Contains('android'))" /> </ItemGroup> <ItemGroup> diff --git a/Mapsui.UI.WinUI/Mapsui.UI.WinUI.csproj b/Mapsui.UI.WinUI/Mapsui.UI.WinUI.csproj index 94abccd885..32a1066b2b 100644 --- a/Mapsui.UI.WinUI/Mapsui.UI.WinUI.csproj +++ b/Mapsui.UI.WinUI/Mapsui.UI.WinUI.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>net8.0-windows10.0.19041.0;net9.0-windows10.0.19041.0</TargetFrameworks> + <TargetFrameworks>net9.0-windows10.0.19041.0</TargetFrameworks> <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> <RootNamespace>Mapsui.UI.WinUI</RootNamespace> <RuntimeIdentifiers>win10-x86;win10-x64;win10-arm64</RuntimeIdentifiers> diff --git a/Mapsui.UI.WindowsForms/Mapsui.UI.WindowsForms.csproj b/Mapsui.UI.WindowsForms/Mapsui.UI.WindowsForms.csproj index 91ba55dbd5..79617f17e8 100644 --- a/Mapsui.UI.WindowsForms/Mapsui.UI.WindowsForms.csproj +++ b/Mapsui.UI.WindowsForms/Mapsui.UI.WindowsForms.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>net8.0-windows;net9.0-windows</TargetFrameworks> + <TargetFrameworks>net9.0-windows</TargetFrameworks> <Nullable>enable</Nullable> <UseWindowsForms>true</UseWindowsForms> <ImplicitUsings>enable</ImplicitUsings> diff --git a/Mapsui.UI.Wpf/Mapsui.UI.Wpf.csproj b/Mapsui.UI.Wpf/Mapsui.UI.Wpf.csproj index fd03a2dd28..c657853a5b 100644 --- a/Mapsui.UI.Wpf/Mapsui.UI.Wpf.csproj +++ b/Mapsui.UI.Wpf/Mapsui.UI.Wpf.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk.WindowsDesktop"> <PropertyGroup> - <TargetFrameworks>net8.0-windows;net9.0-windows</TargetFrameworks> + <TargetFrameworks>net9.0-windows</TargetFrameworks> <UseWpf>true</UseWpf> <PackageId>Mapsui.Wpf</PackageId> <Description>WPF map components based on the Mapsui library</Description> diff --git a/Mapsui.UI.iOS/MapControl.cs b/Mapsui.UI.iOS/MapControl.cs index 07ae022401..0c452799dc 100644 --- a/Mapsui.UI.iOS/MapControl.cs +++ b/Mapsui.UI.iOS/MapControl.cs @@ -13,6 +13,7 @@ public partial class MapControl : UIView, IMapControl private SKCanvasView? _canvas; private bool _canvasInitialized; private readonly ManipulationTracker _manipulationTracker = new(); + private readonly List<UITouch> _activeTouches = []; // In the order in which the fingers touched down. public static bool UseGPU { get; set; } = true; public MapControl(CGRect frame) @@ -125,7 +126,9 @@ public override void TouchesBegan(NSSet touches, UIEvent? e) Catch.Exceptions(() => { base.TouchesBegan(touches, e); - var positions = GetScreenPositions(e, this); + RemoveTouchesNotIn(e); + AddTouches(touches); + var positions = GetScreenPositions(); if (positions.Length == 1) _manipulationTracker.Restart(positions); @@ -140,7 +143,7 @@ public override void TouchesMoved(NSSet touches, UIEvent? e) Catch.Exceptions(() => { base.TouchesMoved(touches, e); - var positions = GetScreenPositions(e, this); + var positions = GetScreenPositions(); if (OnPointerMoved(positions, false)) return; @@ -154,18 +157,68 @@ public override void TouchesEnded(NSSet touches, UIEvent? e) Catch.Exceptions(() => { base.TouchesEnded(touches, e); - var positions = GetScreenPositions(e, this); + // The lifted touches are still included here because OnPointerReleased only handles + // the release of the last finger, which it recognizes by a single position. + var positions = GetScreenPositions(); + RemoveTouches(touches); OnPointerReleased(positions); }); } - private static ReadOnlySpan<ScreenPosition> GetScreenPositions(UIEvent? uiEvent, UIView uiView) + public override void TouchesCancelled(NSSet touches, UIEvent? e) { - if (uiEvent is null) - return []; - return uiEvent?.AllTouches?.Select(t => ((UITouch)t).LocationInView(uiView)).Select(p => new ScreenPosition(p.X, p.Y)).ToArray() ?? []; + Catch.Exceptions(() => + { + base.TouchesCancelled(touches, e); + // No OnPointerReleased call, because a cancelled touch, for instance by an incoming call, + // should not result in a tap or fling. + RemoveTouches(touches); + }); + } + + private ScreenPosition[] GetScreenPositions() + { + // The positions are taken from _activeTouches instead of UIEvent.AllTouches because AllTouches is an + // unordered NSSet. The ManipulationTracker derives the rotation from the first two positions, so if + // two fingers swap places between events the map rotates 180 degrees. See issue #2738. + var positions = new ScreenPosition[_activeTouches.Count]; + for (var i = 0; i < _activeTouches.Count; i++) + { + var location = _activeTouches[i].LocationInView(this); + positions[i] = new ScreenPosition(location.X, location.Y); + } + return positions; + } + + private void AddTouches(NSSet touches) + { + // UIKit reuses the same UITouch instance for a finger during the entire gesture, + // so it can be used to identify that finger. + foreach (var touch in touches.OfType<UITouch>()) + if (IndexOfTouch(touch) < 0) + _activeTouches.Add(touch); + } + + private void RemoveTouches(NSSet touches) + { + foreach (var touch in touches.OfType<UITouch>()) + { + var index = IndexOfTouch(touch); + if (index >= 0) + _activeTouches.RemoveAt(index); + } } + private void RemoveTouchesNotIn(UIEvent? e) + { + // Safeguard against touches that never received an ended or cancelled callback, + // for instance because the view was removed from the window during a gesture. + var allTouches = e?.AllTouches?.OfType<UITouch>().Select(t => t.Handle).ToHashSet() ?? []; + _activeTouches.RemoveAll(t => !allTouches.Contains(t.Handle)); + } + + private int IndexOfTouch(UITouch touch) => _activeTouches.FindIndex(t => t.Handle == touch.Handle); + private static void RunOnUIThread(Action action) { DispatchQueue.MainQueue.DispatchAsync(action); diff --git a/Mapsui/Fetcher/DataFetcher.cs b/Mapsui/Fetcher/DataFetcher.cs index 1bf78c5487..5b94b0f7b0 100644 --- a/Mapsui/Fetcher/DataFetcher.cs +++ b/Mapsui/Fetcher/DataFetcher.cs @@ -25,9 +25,13 @@ public sealed class DataFetcher : IDisposable public DataFetcher(Func<IEnumerable<IFetchableSource>> getFetchableSources) // The constructor accepts a function so that it works for changes to the layer list. { _getFetchableSources = getFetchableSources; - _consumerTask = Task.Run(() => AddConsumerAsync(_channel, _cancellationTokenSource.Token)); + // Get the token before Task.Run. Accessing it inside the delegate throws if Dispose runs before the task starts. + var cancellationToken = _cancellationTokenSource.Token; + _consumerTask = Task.Run(() => AddConsumerAsync(_channel, cancellationToken)); } + internal Task ConsumerTask => _consumerTask; + public void ViewportChanged(FetchInfo fetchInfo) { if (_disposed) diff --git a/Mapsui/Layers/BaseLayer.cs b/Mapsui/Layers/BaseLayer.cs index 18539cab8b..d5f8eba020 100644 --- a/Mapsui/Layers/BaseLayer.cs +++ b/Mapsui/Layers/BaseLayer.cs @@ -177,6 +177,14 @@ protected set /// <inheritdoc /> public HyperlinkWidget Attribution { get; set; } = new(); + /// <summary> + /// The extra margin in pixels added around the viewport extent when querying and fetching features. + /// Increase this when the layer's style renders visually larger than the feature's geometry + /// (e.g. wide lines, large symbols, labels, or custom renderers that overflow the geometry bounds). + /// This prevents features from disappearing at the edge of the screen. + /// </summary> + public double VisibilityMargin { get; set; } = SymbolStyle.DefaultWidth * 2; + /// <inheritdoc /> public virtual IReadOnlyList<double> Resolutions { get; } = []; diff --git a/Mapsui/Layers/GenericCollectionLayer.cs b/Mapsui/Layers/GenericCollectionLayer.cs index 3a5ec366a1..3d3c26bfe5 100644 --- a/Mapsui/Layers/GenericCollectionLayer.cs +++ b/Mapsui/Layers/GenericCollectionLayer.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using Mapsui.Extensions; -using Mapsui.Styles; namespace Mapsui.Layers; @@ -19,9 +18,7 @@ public override IEnumerable<IFeature> GetFeatures(MRect? rect, double resolution // Safeguard in case BoundingBox is null, most likely due to no features in layer if (rect == null) { return new List<IFeature>(); } - var biggerRect = rect.Grow( - SymbolStyle.DefaultWidth * 2 * resolution, - SymbolStyle.DefaultHeight * 2 * resolution); + var biggerRect = rect.Grow(VisibilityMargin * resolution); return Features.Where(f => f.Extent?.Intersects(biggerRect) == true); } diff --git a/Mapsui/Layers/Layer.cs b/Mapsui/Layers/Layer.cs index 4b87576166..521839b0d1 100644 --- a/Mapsui/Layers/Layer.cs +++ b/Mapsui/Layers/Layer.cs @@ -12,7 +12,6 @@ using Mapsui.Layers.AnimatedLayers; using Mapsui.Logging; using Mapsui.Providers; -using Mapsui.Styles; namespace Mapsui.Layers; @@ -135,7 +134,8 @@ public async Task FetchAsync(FetchInfo fetchInfo, int refreshCounter) { try { - fetchInfo = fetchInfo.Grow(SymbolStyle.DefaultWidth); + var grownExtent = fetchInfo.Extent.Grow(VisibilityMargin * fetchInfo.Resolution); + fetchInfo = new FetchInfo(new MSection(grownExtent, fetchInfo.Resolution), fetchInfo.CRS, fetchInfo.ChangeType); var features = DataSource != null ? await DataSource.GetFeaturesAsync(fetchInfo).ConfigureAwait(false) : []; _cache = features.ToArray(); _hasAnimatedFeatures = _cache.Any(f => f is IAnimatedFeature); diff --git a/Mapsui/Layers/MemoryLayer.cs b/Mapsui/Layers/MemoryLayer.cs index ce8157ae7a..dfb68f662a 100644 --- a/Mapsui/Layers/MemoryLayer.cs +++ b/Mapsui/Layers/MemoryLayer.cs @@ -1,5 +1,4 @@ using Mapsui.Extensions; -using Mapsui.Styles; using System; using System.Collections.Generic; using System.Linq; @@ -48,9 +47,7 @@ public override IEnumerable<IFeature> GetFeatures(MRect? rect, double resolution if (rect == null) yield break; - var biggerRect = rect.Grow( - SymbolStyle.DefaultWidth * 2 * resolution, - SymbolStyle.DefaultHeight * 2 * resolution); + var biggerRect = rect.Grow(VisibilityMargin * resolution); foreach (var feature in _localFeatures) { if (feature?.Extent?.Intersects(biggerRect) == true) diff --git a/Mapsui/Layers/WritableLayer.cs b/Mapsui/Layers/WritableLayer.cs index 2b627c929b..0e7c3f6cb3 100644 --- a/Mapsui/Layers/WritableLayer.cs +++ b/Mapsui/Layers/WritableLayer.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Mapsui.Styles; using Mapsui.Utilities; namespace Mapsui.Layers; @@ -15,7 +14,7 @@ public override IEnumerable<IFeature> GetFeatures(MRect? box, double resolution) // Safeguard in case MRect is null, most likely due to no features in layer if (box == null) return new List<IFeature>(); var cache = _cache; - var biggerBox = box.Grow(SymbolStyle.DefaultWidth * 2 * resolution, SymbolStyle.DefaultHeight * 2 * resolution); + var biggerBox = box.Grow(VisibilityMargin * resolution); var result = cache.Where(f => biggerBox.Intersects(f.Extent)); return result; } diff --git a/Mapsui/Projections/CrsAxisOrderRegistry.cs b/Mapsui/Projections/CrsAxisOrderRegistry.cs index ab9c5355ac..5852ab0446 100644 --- a/Mapsui/Projections/CrsAxisOrderRegistry.cs +++ b/Mapsui/Projections/CrsAxisOrderRegistry.cs @@ -251,7 +251,7 @@ public int[] this[CrsIdentifier identifier] const int maxSrid = 32767; // PDD: I don't know where this number comes from. I based it on the comment below. - switch (identifier.Authority.ToUpper()) + switch (identifier.Authority.ToUpperInvariant()) { //case "OGC": default: diff --git a/Mapsui/Projections/CrsHelper.cs b/Mapsui/Projections/CrsHelper.cs index f7fdaf3add..67b1ea6cff 100644 --- a/Mapsui/Projections/CrsHelper.cs +++ b/Mapsui/Projections/CrsHelper.cs @@ -39,9 +39,9 @@ public static CrsType GetCrsType(string crs) { ArgumentNullException.ThrowIfNull(crs); - if (crs.StartsWith(EpsgPrefix)) return CrsType.Epgs; - if (crs.StartsWith(EsriStringPrefix)) return CrsType.EsriString; - if (crs.StartsWith(Proj4StringPrefix)) return CrsType.Proj4String; + if (crs.StartsWith(EpsgPrefix, StringComparison.Ordinal)) return CrsType.Epgs; + if (crs.StartsWith(EsriStringPrefix, StringComparison.Ordinal)) return CrsType.EsriString; + if (crs.StartsWith(Proj4StringPrefix, StringComparison.Ordinal)) return CrsType.Proj4String; throw new Exception($"crs not recognized: '{crs}'"); } diff --git a/Mapsui/Providers/Wms/WmsProvider.cs b/Mapsui/Providers/Wms/WmsProvider.cs index 381fe8df32..8b1c2842a1 100644 --- a/Mapsui/Providers/Wms/WmsProvider.cs +++ b/Mapsui/Providers/Wms/WmsProvider.cs @@ -561,7 +561,7 @@ private Client.WmsOnlineResource GetInfoPreferredMethod() public bool? IsCrsSupported(string crs) { if (_wmsClient == null) return null; - return _wmsClient.Layer.CRS.FirstOrDefault(item => string.Equals(item.Trim(), crs.Trim(), StringComparison.CurrentCultureIgnoreCase)) != null; + return _wmsClient.Layer.CRS.FirstOrDefault(item => string.Equals(item.Trim(), crs.Trim(), StringComparison.OrdinalIgnoreCase)) != null; } public async Task<IEnumerable<IFeature>> GetFeaturesAsync(FetchInfo fetchInfo) @@ -588,7 +588,7 @@ private async Task<byte[]> GetBytesAsync(string url) } } - using var req = new HttpRequestMessage(new HttpMethod(GetPreferredMethod().Type?.ToUpper() ?? "GET"), url); + using var req = new HttpRequestMessage(new HttpMethod(GetPreferredMethod().Type?.ToUpperInvariant() ?? "GET"), url); using var response = await client.SendAsync(req).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -596,7 +596,7 @@ private async Task<byte[]> GetBytesAsync(string url) throw new Exception($"Unexpected WMS response code: {response.StatusCode}"); } - if (response.Content.Headers.ContentType?.MediaType?.ToLower() != _mimeType) + if (!string.Equals(response.Content.Headers.ContentType?.MediaType, _mimeType, StringComparison.OrdinalIgnoreCase)) { throw new Exception($"Unexpected WMS response content type. Expected - {_mimeType}, got - {response.Content.Headers.ContentType?.MediaType}"); } diff --git a/Mapsui/Styles/Color.cs b/Mapsui/Styles/Color.cs index 1592c9d7d1..1a41c77b4f 100644 --- a/Mapsui/Styles/Color.cs +++ b/Mapsui/Styles/Color.cs @@ -352,7 +352,9 @@ public static explicit operator System.Drawing.Color(Color color) /// <returns>Converted Mapsui Color</returns> public static Color FromString(string from) { - from = from.Trim().ToLower(); + // Invariant, because a culture-sensitive ToLower() turns 'I' into a dotless 'ı' under Turkish culture. + // See https://github.com/Mapsui/Mapsui/issues/3383 + from = from.Trim().ToLowerInvariant(); if (from.StartsWith('#')) { @@ -373,7 +375,7 @@ public static Color FromString(string from) } throw new ArgumentException("The color # format should contain 3 or 6 hex numbers"); } - else if (from.StartsWith("rgba")) + else if (from.StartsWith("rgba", StringComparison.Ordinal)) { var split = from[(from.IndexOf('(') + 1)..].TrimEnd(')').Split(','); @@ -387,7 +389,7 @@ public static Color FromString(string from) return new Color(r, g, b, (int)(a * 255)); } - else if (from.StartsWith("rgb")) + else if (from.StartsWith("rgb", StringComparison.Ordinal)) { var split = from[(from.IndexOf('(') + 1)..].TrimEnd(')').Split(','); @@ -400,7 +402,7 @@ public static Color FromString(string from) return new Color(r, g, b); } - else if (from.StartsWith("hsla")) + else if (from.StartsWith("hsla", StringComparison.Ordinal)) { var split = from[(from.IndexOf('(') + 1)..].TrimEnd(')').Split(','); @@ -414,7 +416,7 @@ public static Color FromString(string from) return FromHsl(h / 360.0f, s / 100.0f, l / 100.0f, (int)(a * 255)); } - else if (from.StartsWith("hsl")) + else if (from.StartsWith("hsl", StringComparison.Ordinal)) { var split = from[(from.IndexOf('(') + 1)..].TrimEnd(')').Split(','); diff --git a/Mapsui/Styles/ImageFetcher.cs b/Mapsui/Styles/ImageFetcher.cs index 3708263f2c..4c72536af1 100644 --- a/Mapsui/Styles/ImageFetcher.cs +++ b/Mapsui/Styles/ImageFetcher.cs @@ -42,13 +42,19 @@ private static byte[] LoadEmbeddedResourceFromPath(Uri imageSource) { try { + // Known limitation: the index is built once, from the assemblies loaded at the first embedded fetch. + // Resources in assemblies loaded later are not found. Concurrent first fetches may also build the + // index more than once, which is harmless but wasteful. if (_embeddedResources is null) _embeddedResources = LoadEmbeddedResourcePaths(); if (_embeddedResources.TryGetValue(imageSource.Host, out var assembly)) { + // Known limitation: the dictionary lookup already matched the name, but we scan the assembly again + // to get the original casing for GetManifestResourceStream. Storing the original name as part of the + // dictionary value would avoid this. string[] resourceNames = assembly.GetManifestResourceNames(); - var matchingResourceName = resourceNames.FirstOrDefault(r => r.Equals(imageSource.Host, StringComparison.InvariantCultureIgnoreCase)); + var matchingResourceName = resourceNames.FirstOrDefault(r => r.Equals(imageSource.Host, StringComparison.OrdinalIgnoreCase)); if (matchingResourceName != null) { using var stream = assembly.GetManifestResourceStream(matchingResourceName) @@ -68,9 +74,12 @@ private static byte[] LoadEmbeddedResourceFromPath(Uri imageSource) } } - private static ConcurrentDictionary<string, Assembly> LoadEmbeddedResourcePaths() + internal static ConcurrentDictionary<string, Assembly> LoadEmbeddedResourcePaths() { - var result = new ConcurrentDictionary<string, Assembly>(); + // Uri.Host is always lowercased, so the lookup must ignore case. Use an ordinal comparer rather than + // lowercasing the keys: a culture-sensitive ToLower() turns 'I' into a dotless 'ı' under Turkish + // culture, so the keys would never match. See https://github.com/Mapsui/Mapsui/issues/3383 + var result = new ConcurrentDictionary<string, Assembly>(StringComparer.OrdinalIgnoreCase); var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic); foreach (var assembly in assemblies) @@ -79,23 +88,28 @@ private static ConcurrentDictionary<string, Assembly> LoadEmbeddedResourcePaths( foreach (var resourceName in resourceNames) { - if (!resourceName.EndsWith(".resources")) - result.AddOrUpdate(resourceName.ToLower(), (r) => assembly, (r, a) => assembly); + if (!resourceName.EndsWith(".resources", StringComparison.Ordinal)) + result.AddOrUpdate(resourceName, (r) => assembly, (r, a) => assembly); } } return result; } - private async static Task<byte[]> LoadFromUrlAsync(Uri imageSource) + private static Task<byte[]> LoadFromUrlAsync(Uri imageSource) + { + return LoadFromUrlAsync(imageSource, new HttpClientHandler { AllowAutoRedirect = true }); + } + + internal async static Task<byte[]> LoadFromUrlAsync(Uri imageSource, HttpMessageHandler handler) { try { - using HttpClientHandler handler = new HttpClientHandler { AllowAutoRedirect = true }; using var httpClient = new HttpClient(handler); - using HttpResponseMessage response = await httpClient.GetAsync(imageSource, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); + using HttpResponseMessage response = await httpClient.GetAsync(imageSource).ConfigureAwait(false); response.EnsureSuccessStatusCode(); // Throws an exception if the HTTP response status is unsuccessful - await using var tempStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - return tempStream.ToBytes(); + // Read asynchronously. In Blazor WebAssembly the response stream is backed by the browser's Fetch API + // and throws on synchronous reads. See https://github.com/Mapsui/Mapsui/issues/3381 + return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); } catch (Exception ex) { diff --git a/Mapsui/Utilities/EmbeddedResourceLoader.cs b/Mapsui/Utilities/EmbeddedResourceLoader.cs index b2cbbda255..8190527572 100644 --- a/Mapsui/Utilities/EmbeddedResourceLoader.cs +++ b/Mapsui/Utilities/EmbeddedResourceLoader.cs @@ -48,7 +48,8 @@ private static string ConstructExceptionMessage(string path, Assembly assembly) } // Give feedback if there are resources with similar names - var similarNames = resourceNames.Where(name => path.ToLower().Split('.') + var pathParts = path.Split('.'); + var similarNames = resourceNames.Where(name => pathParts .Any(s => name.Contains(s, StringComparison.OrdinalIgnoreCase))).ToArray(); if (similarNames.Length <= 0) return stringBuilder.ToString(); var nameLength = assembly.GetAssemblyName()?.Length ?? 0; diff --git a/Mapsui/Utilities/MetaDataHelper.cs b/Mapsui/Utilities/MetaDataHelper.cs index 03852afd21..0ff29b5b01 100644 --- a/Mapsui/Utilities/MetaDataHelper.cs +++ b/Mapsui/Utilities/MetaDataHelper.cs @@ -21,7 +21,7 @@ public static string GetReadableActionName(Action? action, bool includeDeclaring if (gt > 1) { // Local function pattern: <Outer>g__LocalName|0_1 - var gIndex = name.IndexOf("g__", gt + 1); + var gIndex = name.IndexOf("g__", gt + 1, StringComparison.Ordinal); if (gIndex >= 0) { var pipe = name.IndexOf('|', gIndex + 3); diff --git a/README.md b/README.md index 98c90ac005..6c6b17a389 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,14 @@ Made with [contrib.rocks](https://contrib.rocks). Lots of thanks to our sponsors -<a href="https://github.com/jamesmontemagno"><img src="https://avatars.githubusercontent.com/u/1676321?v=4" width="200px;" alt="Sponsor3" /></a> -<a href="https://github.com/TAlecksen"><img src="https://avatars.githubusercontent.com/u/26470677?v=4" width="200px;" alt="Sponsor1" /></a> -<a href="https://github.com/unoplatform"><img src="https://avatars.githubusercontent.com/u/52228309?v=4" width="200px;" alt="Sponsor2" /></a> +<a href="https://github.com/jamesmontemagno"><img src="https://avatars.githubusercontent.com/u/1676321?v=4" width="200px;" alt="James Montemagno" /></a> +<a href="https://github.com/TAlecksen"><img src="https://avatars.githubusercontent.com/u/26470677?v=4" width="200px;" alt="Tyler Alecksen" /></a> +<a href="https://github.com/unoplatform"><img src="https://avatars.githubusercontent.com/u/52228309?v=4" width="200px;" alt="Uno Platform" /></a> +<a href="https://github.com/Sebastian1989101"><img src="https://avatars.githubusercontent.com/u/25636075?v=4" width="200px;" alt="Sebastian Kruse" /></a> +<a href="https://github.com/Oblimbo"><img src="https://avatars.githubusercontent.com/u/30815498?v=4" width="200px;" alt="Oblimbo" /></a> ### Past Sponsors <a href="https://github.com/P33tr"><img src="https://avatars.githubusercontent.com/u/44436267?v=4" width="40px;" alt="Sponsor4" /></a> -<a href="https://github.com/Sebastian1989101"><img src="https://avatars.githubusercontent.com/u/25636075?v=4" width="40px;" alt="Sponsor5" /></a> <a href="https://github.com/blwhttngtn"><img src="https://avatars.githubusercontent.com/u/12670350?v=4" width="40px;" alt="Sponsor3" /></a> <a href="https://github.com/winkmichael"><img src="https://avatars.githubusercontent.com/u/5185889?v=4" width="40px;" alt="Sponsor5" /></a> diff --git a/Samples/Avalonia/Mapsui.Samples.Avalonia.Android/Icon.png b/Samples/Avalonia/Mapsui.Samples.Avalonia.Android/Icon.png index f1b08c97a7..267a2768c2 100644 Binary files a/Samples/Avalonia/Mapsui.Samples.Avalonia.Android/Icon.png and b/Samples/Avalonia/Mapsui.Samples.Avalonia.Android/Icon.png differ diff --git a/Samples/Avalonia/Mapsui.Samples.Avalonia.Browser/wwwroot/favicon.ico b/Samples/Avalonia/Mapsui.Samples.Avalonia.Browser/wwwroot/favicon.ico index da8d49ff9b..9c5fd07236 100644 Binary files a/Samples/Avalonia/Mapsui.Samples.Avalonia.Browser/wwwroot/favicon.ico and b/Samples/Avalonia/Mapsui.Samples.Avalonia.Browser/wwwroot/favicon.ico differ diff --git a/Samples/Avalonia/Mapsui.Samples.Avalonia/Assets/avalonia-logo.ico b/Samples/Avalonia/Mapsui.Samples.Avalonia/Assets/avalonia-logo.ico index da8d49ff9b..9c5fd07236 100644 Binary files a/Samples/Avalonia/Mapsui.Samples.Avalonia/Assets/avalonia-logo.ico and b/Samples/Avalonia/Mapsui.Samples.Avalonia/Assets/avalonia-logo.ico differ diff --git a/Samples/Avalonia12/Directory.Build.props b/Samples/Avalonia12/Directory.Build.props index 2c741cc72f..a6564059e2 100644 --- a/Samples/Avalonia12/Directory.Build.props +++ b/Samples/Avalonia12/Directory.Build.props @@ -7,8 +7,15 @@ This file shadows the root one (MSBuild stops traversing once it finds the first Directory.Build.props), so we must re-declare the Avalonia 12 version properties here for all projects under this folder. --> - <Avalonia12Version>12.0.2</Avalonia12Version> - <Avalonia12SkiaSharpVersion>3.119.4-preview.1.1</Avalonia12SkiaSharpVersion> - <Avalonia12HarfBuzzSharpVersion>8.3.1.3</Avalonia12HarfBuzzSharpVersion> + <Avalonia12Version>12.1.2</Avalonia12Version> + <!-- + Mapsui.UI.Avalonia12 is compiled against SkiaSharp 3.119.4 (the version Avalonia 12 depends on), + but Avalonia 12.1 also supports SkiaSharp 4 when an app references it explicitly. The samples opt in + to SkiaSharp 4 to demonstrate that combination; keep it in sync with SkiaSharp4CompatibilityVersion + and HarfBuzzSharp4CompatibilityVersion in the root Directory.Build.props. + --> + <Avalonia12SkiaSharpVersion>4.151.2</Avalonia12SkiaSharpVersion> + <Avalonia12HarfBuzzSharpVersion>14.2.1.102</Avalonia12HarfBuzzSharpVersion> + <Avalonia12ReactiveUIVersion>12.1.2</Avalonia12ReactiveUIVersion> </PropertyGroup> </Project> diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Icon.png b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Icon.png index f1b08c97a7..267a2768c2 100644 Binary files a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Icon.png and b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Icon.png differ diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Mapsui.Samples.Avalonia12.Android.csproj b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Mapsui.Samples.Avalonia12.Android.csproj index b877102e8b..8ec127eccd 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Mapsui.Samples.Avalonia12.Android.csproj +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Android/Mapsui.Samples.Avalonia12.Android.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- - Avalonia.Android 12.0.2 only ships net10.0-android36.0 assets — requires .NET 10. + Avalonia.Android 12.1.2 only ships net10.0-android36.0 assets — requires .NET 10. This project is excluded from Mapsui.slnx and Mapsui.Avalonia.slnf because the repo's root global.json pins the SDK to .NET 9. Build from this directory directly: dotnet build --> diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/Mapsui.Samples.Avalonia12.Browser.csproj b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/Mapsui.Samples.Avalonia12.Browser.csproj index 90c366f77d..0b3de75511 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/Mapsui.Samples.Avalonia12.Browser.csproj +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/Mapsui.Samples.Avalonia12.Browser.csproj @@ -21,6 +21,7 @@ <PackageReference Include="HarfBuzzSharp" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> <PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> <PackageReference Include="SkiaSharp" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> + <PackageReference Include="SkiaSharp.HarfBuzz" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> </ItemGroup> diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/wwwroot/favicon.ico b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/wwwroot/favicon.ico index da8d49ff9b..9c5fd07236 100644 Binary files a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/wwwroot/favicon.ico and b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Browser/wwwroot/favicon.ico differ diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Mapsui.Samples.Avalonia12.Desktop.csproj b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Mapsui.Samples.Avalonia12.Desktop.csproj index 9555314d6b..ebf80c39b9 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Mapsui.Samples.Avalonia12.Desktop.csproj +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Mapsui.Samples.Avalonia12.Desktop.csproj @@ -13,9 +13,10 @@ <PackageReference Include="Avalonia.Skia" VersionOverride="$(Avalonia12Version)" /> <PackageReference Include="Avalonia.Themes.Fluent" VersionOverride="$(Avalonia12Version)" /> <PackageReference Include="Avalonia.Fonts.Inter" VersionOverride="$(Avalonia12Version)" /> - <PackageReference Include="ReactiveUI.Avalonia" VersionOverride="12.0.1" /> - <!-- SkiaSharp and HarfBuzz must match the Avalonia 12 versions --> + <PackageReference Include="ReactiveUI.Avalonia" VersionOverride="$(Avalonia12ReactiveUIVersion)" /> + <!-- Repeated here because central transitive pinning would otherwise pull the app back to the catalog versions --> <PackageReference Include="SkiaSharp" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> + <PackageReference Include="SkiaSharp.HarfBuzz" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="HarfBuzzSharp" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Program.cs b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Program.cs index 297505e122..91c33ff0c0 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Program.cs +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.Desktop/Program.cs @@ -19,9 +19,10 @@ public static AppBuilder BuildAvaloniaApp() .UsePlatformDetect() .With(new Win32PlatformOptions { + // No Vulkan: this sample runs on SkiaSharp 4, and Avalonia 12.1's Vulkan backend crashes + // with SkiaSharp 4 (access violation in VulkanDevice.Create). Restore it once Avalonia fixes that. RenderingMode = [ - Win32RenderingMode.Vulkan, // Try Vulkan first (fastest if supported) - Win32RenderingMode.AngleEgl, // Try ANGLE (uses DirectX) + Win32RenderingMode.AngleEgl, // Try ANGLE first (uses DirectX) Win32RenderingMode.Wgl, // Then try native OpenGL Win32RenderingMode.Software // Fall back to software if needed ], diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.iOS/Mapsui.Samples.Avalonia12.iOS.csproj b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.iOS/Mapsui.Samples.Avalonia12.iOS.csproj index 4a574bfcf7..88cb7df481 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12.iOS/Mapsui.Samples.Avalonia12.iOS.csproj +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12.iOS/Mapsui.Samples.Avalonia12.iOS.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- - Avalonia.iOS 12.0.2 only ships net10.0-ios assets — requires .NET 10. + Avalonia.iOS 12.1.2 only ships net10.0-ios assets — requires .NET 10. This project is excluded from Mapsui.slnx and Mapsui.Avalonia.slnf because the repo's root global.json pins the SDK to .NET 9. Build from this directory directly: dotnet build --> diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Assets/avalonia-logo.ico b/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Assets/avalonia-logo.ico index da8d49ff9b..9c5fd07236 100644 Binary files a/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Assets/avalonia-logo.ico and b/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Assets/avalonia-logo.ico differ diff --git a/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Mapsui.Samples.Avalonia12.csproj b/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Mapsui.Samples.Avalonia12.csproj index 96eb06b626..2267ab222f 100644 --- a/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Mapsui.Samples.Avalonia12.csproj +++ b/Samples/Avalonia12/Mapsui.Samples.Avalonia12/Mapsui.Samples.Avalonia12.csproj @@ -15,13 +15,16 @@ <PackageReference Include="Avalonia" VersionOverride="$(Avalonia12Version)" /> <PackageReference Include="Avalonia.Themes.Fluent" VersionOverride="$(Avalonia12Version)" /> <PackageReference Include="Avalonia.Fonts.Inter" VersionOverride="$(Avalonia12Version)" /> - <PackageReference Include="ReactiveUI.Avalonia" VersionOverride="12.0.1" /> - <!-- SkiaSharp and HarfBuzz must match what Avalonia 12 requires --> + <PackageReference Include="ReactiveUI.Avalonia" VersionOverride="$(Avalonia12ReactiveUIVersion)" /> + <!-- SkiaSharp and HarfBuzz packages must all be on one release train, see Samples/Avalonia12/Directory.Build.props --> <PackageReference Include="Avalonia.Skia" VersionOverride="$(Avalonia12Version)" /> <PackageReference Include="SkiaSharp" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> + <PackageReference Include="SkiaSharp.HarfBuzz" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" VersionOverride="$(Avalonia12SkiaSharpVersion)" /> <PackageReference Include="HarfBuzzSharp" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> + <!-- Avalonia.HarfBuzz pulls in the 8.x Linux native asset, which does not match the 14.x managed HarfBuzzSharp --> + <PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> <PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" VersionOverride="$(Avalonia12HarfBuzzSharpVersion)" /> </ItemGroup> diff --git a/Samples/Mapsui.Samples.Blazor/Pages/Index.razor.cs b/Samples/Mapsui.Samples.Blazor/Pages/Index.razor.cs index 5c556e9dbd..5a3888eebd 100644 --- a/Samples/Mapsui.Samples.Blazor/Pages/Index.razor.cs +++ b/Samples/Mapsui.Samples.Blazor/Pages/Index.razor.cs @@ -194,10 +194,10 @@ private bool TryParseHash(string uri, out string? category, out string? name) var u = new Uri(uri); var hash = u.Fragment; // includes leading '#' if (string.IsNullOrEmpty(hash)) return false; - if (!hash.StartsWith("#")) return false; + if (!hash.StartsWith('#')) return false; var path = hash.Length > 1 ? hash.Substring(1) : string.Empty; // remove '#' // Support both '#/Category/Name' and '#Category/Name' - if (path.StartsWith("/")) path = path.Substring(1); + if (path.StartsWith('/')) path = path.Substring(1); if (string.IsNullOrEmpty(path)) return false; var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 1) category = Uri.UnescapeDataString(parts[0]); diff --git a/Samples/Mapsui.Samples.Blazor/wwwroot/codesamples/HyperlinkSample.html b/Samples/Mapsui.Samples.Blazor/wwwroot/codesamples/HyperlinkSample.html index a31178c5f8..4c9bc4caf5 100644 --- a/Samples/Mapsui.Samples.Blazor/wwwroot/codesamples/HyperlinkSample.html +++ b/Samples/Mapsui.Samples.Blazor/wwwroot/codesamples/HyperlinkSample.html @@ -36,7 +36,7 @@ var map = new Map(); map.Layers.Add(OpenStreetMap.CreateTileLayer()); - map.Widgets.Add(CreateHyperlink("Open Mapsui FAQ", "https://mapsui.com/documentation/faq.html", + map.Widgets.Add(CreateHyperlink("Open Mapsui FAQ", "https://mapsui.com/v5/faq/", VerticalAlignment.Top, HorizontalAlignment.Left)); map.Widgets.Add(CreateHyperlink("Open Mapsui readme.md", "https://github.com/Mapsui/Mapsui/blob/main/README.md", VerticalAlignment.Bottom, HorizontalAlignment.Right)); diff --git a/Samples/Mapsui.Samples.Blazor/wwwroot/favicon.ico b/Samples/Mapsui.Samples.Blazor/wwwroot/favicon.ico index 63e859b476..a491605625 100644 Binary files a/Samples/Mapsui.Samples.Blazor/wwwroot/favicon.ico and b/Samples/Mapsui.Samples.Blazor/wwwroot/favicon.ico differ diff --git a/Samples/Mapsui.Samples.Common/Maps/Styles/CustomLayerMatrixSample.cs b/Samples/Mapsui.Samples.Common/Maps/Styles/CustomLayerMatrixSample.cs new file mode 100644 index 0000000000..499b90d3d8 --- /dev/null +++ b/Samples/Mapsui.Samples.Common/Maps/Styles/CustomLayerMatrixSample.cs @@ -0,0 +1,98 @@ +using Mapsui.Extensions; +using Mapsui.Layers; +using Mapsui.Projections; +using Mapsui.Rendering; +using Mapsui.Rendering.Skia; +using Mapsui.Styles; +using Mapsui.Tiling; +using SkiaSharp; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Mapsui.Samples.Common.Maps.Styles; + +/// <summary> +/// Integrates an existing two-dimensional Skia drawing with the map by transforming the entire scene. +/// </summary> +public class CustomLayerMatrixSample : ISample +{ + private const string _rendererName = "custom-layer-matrix"; + private static readonly MPoint _origin = new(SphericalMercator.FromLonLat(4.9, 52.37)); + private static readonly SKPoint[] _nodes = [new(-120, -80), new(100, -80), new(100, 90), new(-120, 90)]; + + public string Name => "Skia scene with a matrix"; + public string Category => nameof(CustomLayerRenderer); + + public Task<Map> CreateMapAsync() + { + var map = new Map { CRS = "EPSG:3857" }; + map.Layers.Add(OpenStreetMap.CreateTileLayer()); + + // A layer callback draws the existing scene in the same frame and viewport as the map. + // The scene does not need to be converted into individual Mapsui features. + map.Layers.Add(new MemoryLayer("Skia scene") { CustomLayerRendererName = _rendererName, Style = null }); + MapRenderer.RegisterLayerRenderer(_rendererName, DrawLayer); + Experimental.Rendering.Skia.MapRenderer.RegisterLayerRenderer(_rendererName, DrawLayer); + + // The red Mapsui dots should stay at the centers of the orange Skia circles during pan, zoom and rotation. + map.Layers.Add(new MemoryLayer("Alignment markers") + { + Features = _nodes.Select(p => new PointFeature(_origin.X + p.X, _origin.Y + p.Y)).ToArray(), + Style = new SymbolStyle { Fill = new Brush(Color.Red), Outline = null, SymbolScale = 0.2 } + }); + + // Prepare the scene's paint before drawing; the map's cache owns its lifetime. + using var paint = map.RenderService.VectorCache.GetOrCreate(_rendererName, CreatePaint); + map.Navigator.CenterOnAndZoomTo(_origin, 1); + map.Navigator.RotateTo(20); + return Task.FromResult(map); + } + + private static SKPaint CreatePaint(string _) => new() { Color = new SKColor(230, 100, 15), Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true }; + + private static void DrawLayer(SKCanvas canvas, Viewport viewport, ILayer layer, RenderService renderService) + { + var matrix = CreateSceneToScreenMatrix(viewport, _origin); + using var paint = renderService.VectorCache.GetOrCreate(_rendererName, CreatePaint); + + // Optional: keep strokes two device-independent pixels wide while the geometry zooms. + // Use a fixed width in scene units instead if the existing drawing's strokes should zoom too. + var scenePaint = paint.Instance; + scenePaint.StrokeWidth = (float)(2 * viewport.Resolution); + + using var restore = new SKAutoCanvasRestore(canvas); + // Preserve the canvas's pixel-density scale. Replacing its matrix would lose that scale. + canvas.Concat(matrix); + DrawScene(canvas, scenePaint); + } + + private static SKMatrix CreateSceneToScreenMatrix(Viewport viewport, MPoint origin) + { + // Scene coordinates are offsets in the map's projected CRS, with Y pointing north. + // Project GNSS coordinates and georeference metric CAD data into that CRS first: + // Web Mercator map units are not ground metres, and this matrix is not a geographic projection. + // Subtract the nearby origin in double precision before storing scene coordinates as floats. + var (screenX, screenY) = viewport.WorldToScreenXY(origin.X, origin.Y); + var radians = viewport.IsRotated() ? Math.IEEERemainder(viewport.Rotation, 360) * Math.PI / 180 : 0; + var cos = Math.Cos(radians) / viewport.Resolution; + var sin = Math.Sin(radians) / viewport.Resolution; + + // Apply zoom, rotation and the north-up to screen-down Y flip to every scene primitive at once. + return new SKMatrix((float)cos, (float)sin, (float)screenX, + (float)sin, (float)-cos, (float)screenY, 0, 0, 1); + } + + private static void DrawScene(SKCanvas canvas, SKPaint paint) + { + // This stands in for an existing Skia renderer: it knows nothing about Mapsui or its viewport. + // Its coordinates must remain in scene space; do not also apply an independent camera transform. + for (var i = 0; i < _nodes.Length; i++) + { + canvas.DrawLine(_nodes[i], _nodes[(i + 1) % _nodes.Length], paint); + canvas.DrawCircle(_nodes[i], 12, paint); + } + canvas.DrawLine(_nodes[0], _nodes[2], paint); + canvas.DrawRect(-35, -20, 70, 40, paint); + } +} diff --git a/Samples/Mapsui.Samples.Common/Maps/Styles/ThemeStyleSample.cs b/Samples/Mapsui.Samples.Common/Maps/Styles/ThemeStyleSample.cs index cb9994cd0a..8290a9671f 100644 --- a/Samples/Mapsui.Samples.Common/Maps/Styles/ThemeStyleSample.cs +++ b/Samples/Mapsui.Samples.Common/Maps/Styles/ThemeStyleSample.cs @@ -93,7 +93,7 @@ private static ThemeStyle CreateThemeStyle() if (geometryFeature.Geometry is Point) return null; - return f["NAME"]?.ToString()?.ToLower() switch + return f["NAME"]?.ToString()?.ToLowerInvariant() switch { "brazil" => brazilStyle, "united states" => usaStyle, diff --git a/Samples/Mapsui.Samples.Common/Maps/Widgets/HyperlinkSample.cs b/Samples/Mapsui.Samples.Common/Maps/Widgets/HyperlinkSample.cs index bb2618734a..4fe1fe08a6 100644 --- a/Samples/Mapsui.Samples.Common/Maps/Widgets/HyperlinkSample.cs +++ b/Samples/Mapsui.Samples.Common/Maps/Widgets/HyperlinkSample.cs @@ -17,7 +17,7 @@ public Task<Map> CreateMapAsync() var map = new Map(); map.Layers.Add(OpenStreetMap.CreateTileLayer()); - map.Widgets.Add(CreateHyperlink("Open Mapsui FAQ", "https://mapsui.com/documentation/faq.html", + map.Widgets.Add(CreateHyperlink("Open Mapsui FAQ", "https://mapsui.com/v5/faq/", VerticalAlignment.Top, HorizontalAlignment.Left)); map.Widgets.Add(CreateHyperlink("Open Mapsui readme.md", "https://github.com/Mapsui/Mapsui/blob/main/README.md", VerticalAlignment.Bottom, HorizontalAlignment.Right)); diff --git a/Samples/Mapsui.Samples.Common/wwwroot/codesamples/HyperlinkSample.html b/Samples/Mapsui.Samples.Common/wwwroot/codesamples/HyperlinkSample.html index a2f50a7ccb..b746524e13 100644 Binary files a/Samples/Mapsui.Samples.Common/wwwroot/codesamples/HyperlinkSample.html and b/Samples/Mapsui.Samples.Common/wwwroot/codesamples/HyperlinkSample.html differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher.png index ce039a544e..7360814661 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_foreground.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_foreground.png index 3550ef98fe..dc7736e31d 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_foreground.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_round.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_round.png index 99e9850d74..7360814661 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_round.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher.png index 795ea7c005..3c3ac52e28 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_foreground.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_foreground.png index f3d020031a..1af66e9cb9 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_foreground.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_round.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_round.png index 8f56909cdd..3c3ac52e28 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_round.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher.png index 04278f0b31..d3b65a6540 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_foreground.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_foreground.png index 53b5520183..615857fb78 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_foreground.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_round.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_round.png index a443d507f0..d3b65a6540 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_round.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher.png index 23d9aa655f..bc37ce5534 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_foreground.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_foreground.png index a845d6429c..ec54f2b54f 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_foreground.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_round.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_round.png index 6227fce712..bc37ce5534 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_round.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher.png index b51911e193..84a1e36f53 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png index fa5c7c38c7..80fa24e3be 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_round.png b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_round.png index 6197e6aac5..84a1e36f53 100644 Binary files a/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_round.png and b/Samples/Mapsui.Samples.Droid/Resources/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Samples/Mapsui.Samples.Droid/Resources/values/ic_launcher_background.xml b/Samples/Mapsui.Samples.Droid/Resources/values/ic_launcher_background.xml index 6ec24e6413..f42ada656e 100644 --- a/Samples/Mapsui.Samples.Droid/Resources/values/ic_launcher_background.xml +++ b/Samples/Mapsui.Samples.Droid/Resources/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <color name="ic_launcher_background">#2C3E50</color> -</resources> \ No newline at end of file + <color name="ic_launcher_background">#FFFFFF</color> +</resources> diff --git a/Samples/Mapsui.Samples.Eto/MacIcon.icns b/Samples/Mapsui.Samples.Eto/MacIcon.icns index 8f385bb4a5..3b0f5538ed 100644 Binary files a/Samples/Mapsui.Samples.Eto/MacIcon.icns and b/Samples/Mapsui.Samples.Eto/MacIcon.icns differ diff --git a/Samples/Mapsui.Samples.Maui.MapView/Mapsui.Samples.Maui.MapView.csproj b/Samples/Mapsui.Samples.Maui.MapView/Mapsui.Samples.Maui.MapView.csproj index 1877048c2b..599fdbf9b6 100644 --- a/Samples/Mapsui.Samples.Maui.MapView/Mapsui.Samples.Maui.MapView.csproj +++ b/Samples/Mapsui.Samples.Maui.MapView/Mapsui.Samples.Maui.MapView.csproj @@ -55,10 +55,10 @@ <ItemGroup> <!-- App Icon --> - <MauiIcon Include="Resources\appicon.svg" ForegroundFile="Resources\appiconfg.svg" Color="#512BD4" /> + <MauiIcon Include="Resources\appicon.svg" /> <!-- Splash Screen --> - <MauiSplashScreen Include="Resources\appiconfg.svg" Color="#512BD4" /> + <MauiSplashScreen Include="Resources\appicon.svg" Color="#ffffff" /> <!-- Images --> <MauiImage Include="Resources\Images\*" /> diff --git a/Samples/Mapsui.Samples.Maui.MapView/Platforms/Windows/Package.appxmanifest b/Samples/Mapsui.Samples.Maui.MapView/Platforms/Windows/Package.appxmanifest index 06597a214e..b6b5508e0a 100644 --- a/Samples/Mapsui.Samples.Maui.MapView/Platforms/Windows/Package.appxmanifest +++ b/Samples/Mapsui.Samples.Maui.MapView/Platforms/Windows/Package.appxmanifest @@ -46,7 +46,7 @@ <uap:ShowOn Tile="wide310x150Logo"/> </uap:ShowNameOnTiles> </uap:DefaultTile > - <uap:SplashScreen Image="appiconfgSplashScreen.png" /> + <uap:SplashScreen Image="appiconSplashScreen.png" /> </uap:VisualElements> </Application> </Applications> diff --git a/Samples/Mapsui.Samples.Maui.MapView/Resources/appicon.svg b/Samples/Mapsui.Samples.Maui.MapView/Resources/appicon.svg index 9d63b6513a..802dbc7c2f 100644 --- a/Samples/Mapsui.Samples.Maui.MapView/Resources/appicon.svg +++ b/Samples/Mapsui.Samples.Maui.MapView/Resources/appicon.svg @@ -1,4 +1,67 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg"> - <rect x="0" y="0" width="456" height="456" fill="#512BD4" /> -</svg> \ No newline at end of file +<svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 512 512" + role="img" + aria-labelledby="title desc" +> + <title id="title">Mapsui logo diagonal gradient + + A purple circle with a cubic diagonal gradient and matching top and bottom cutouts. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples/Mapsui.Samples.Maui.MapView/Resources/appiconfg.svg b/Samples/Mapsui.Samples.Maui.MapView/Resources/appiconfg.svg deleted file mode 100644 index 21dfb25f18..0000000000 --- a/Samples/Mapsui.Samples.Maui.MapView/Resources/appiconfg.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Samples/Mapsui.Samples.Maui/Mapsui.Samples.Maui.csproj b/Samples/Mapsui.Samples.Maui/Mapsui.Samples.Maui.csproj index 11a8c87e47..9ceea3dfaa 100644 --- a/Samples/Mapsui.Samples.Maui/Mapsui.Samples.Maui.csproj +++ b/Samples/Mapsui.Samples.Maui/Mapsui.Samples.Maui.csproj @@ -43,10 +43,10 @@ - + - + @@ -59,6 +59,16 @@ + + + + + + + + diff --git a/Samples/Mapsui.Samples.Maui/Model/SampleScene.cs b/Samples/Mapsui.Samples.Maui/Model/SampleScene.cs new file mode 100644 index 0000000000..0de970da24 --- /dev/null +++ b/Samples/Mapsui.Samples.Maui/Model/SampleScene.cs @@ -0,0 +1,75 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using Mapsui.Samples.Common; +using Mapsui.UI.Maui; + +namespace Mapsui.Samples.Maui.Model; + +internal sealed class SampleScene : IDisposable +{ + private readonly ISampleBase _sample; + public MapControl Control { get; } + public bool IsSameSample(ISampleBase selected) => _sample.GetType() == selected.GetType(); + + private SampleScene(ISampleBase sample, RenderBackend backend) + { + _sample = sample; + Control = new MapControl(backend); + Control.Map.Navigator.RotationLock = false; + } + + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "The sample source generator constructs every registered sample directly, preserving its public parameterless constructor.")] + [SuppressMessage("Usage", "IDISP007:Don't dispose injected", Justification = "This scene owns its newly constructed control and the default map being replaced.")] + public static async Task CreateAsync(ISampleBase selected, RenderBackend backend, Viewport? viewport, CancellationToken cancellationToken) + { + // Registry entries are descriptions, never reused as the lifetime owner of a displayed scene. + var sample = (ISampleBase?)Activator.CreateInstance(selected.GetType()) + ?? throw new InvalidOperationException($"Could not create sample {selected.Name}."); + SampleScene? scene = null; + try + { + scene = new SampleScene(sample, backend); + if (sample is ISample mapSample) + { + var map = await mapSample.CreateMapAsync(); + var initialMap = scene.Control.Map; + scene.Control.Map = map; + initialMap.Dispose(); + } + else if (sample is IMapControlSample controlSample) + { + var initialMap = scene.Control.Map; + controlSample.Setup(scene.Control); + if (!ReferenceEquals(initialMap, scene.Control.Map)) + initialMap.Dispose(); + } + cancellationToken.ThrowIfCancellationRequested(); + + scene.Control.Map.Navigator.RotationLock = false; + if (viewport is { Resolution: > 0 } saved) + { + // Queue camera restoration until the new native view has its own dimensions. + scene.Control.Map.Navigator.CenterOnAndZoomTo(new MPoint(saved.CenterX, saved.CenterY), saved.Resolution, 0); + scene.Control.Map.Navigator.RotateTo(saved.Rotation, 0); + } + return scene; + } + catch + { + if (scene != null) + scene.Dispose(); + else + (sample as IDisposable)?.Dispose(); + throw; + } + } + + public void Dispose() + { + Control.Dispose(); + (_sample as IDisposable)?.Dispose(); + } +} diff --git a/Samples/Mapsui.Samples.Maui/Model/SampleSwitchCoordinator.cs b/Samples/Mapsui.Samples.Maui/Model/SampleSwitchCoordinator.cs new file mode 100644 index 0000000000..53508d2bb5 --- /dev/null +++ b/Samples/Mapsui.Samples.Maui/Model/SampleSwitchCoordinator.cs @@ -0,0 +1,63 @@ +// Copyright (c) The Mapsui authors. +// The Mapsui authors licensed this file under the MIT license. +// See the LICENSE file in the project root for full license information. + +#pragma warning disable IDE0005 // Also compiled by the test project, which does not enable implicit usings. +using System; +using System.Threading; +using System.Threading.Tasks; +#pragma warning restore IDE0005 + +namespace Mapsui.Samples.Maui.Model; + +// Called on the UI context. A newer selection cancels publication of an older result, +// even when the sample's CreateMapAsync does not itself accept cancellation. +internal sealed class SampleSwitchCoordinator : IDisposable +{ + private Task _pending = Task.CompletedTask; + private Action? _cancelPending; + private int _version; + private bool _disposed; + + public Task RunAsync(Func action) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _cancelPending?.Invoke(); + var cancellation = new CancellationTokenSource(); + _cancelPending = cancellation.Cancel; + var version = ++_version; + return _pending = ExecuteAsync(_pending, cancellation, version, action); + } + + private async Task ExecuteAsync(Task previous, CancellationTokenSource cancellation, int version, Func action) + { +#pragma warning disable IDISP007 // This private method owns the CTS created by RunAsync for this request. + using (cancellation) +#pragma warning restore IDISP007 + { + try + { + // The caller of the failed request reports its error; it must not prevent the next selection. +#pragma warning disable VSTHRD003 // Previous is our own asynchronous UI request; no synchronous waits are used. + try { await previous; } +#pragma warning restore VSTHRD003 + catch (Exception) { } + cancellation.Token.ThrowIfCancellationRequested(); + await action(cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } + finally + { + if (version == _version) + _cancelPending = null; + } + } + } + + public void Dispose() + { + _disposed = true; + _cancelPending?.Invoke(); + _cancelPending = null; + } +} diff --git a/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appicon.svg b/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appicon.svg index 9d63b6513a..802dbc7c2f 100644 --- a/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appicon.svg +++ b/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appicon.svg @@ -1,4 +1,67 @@ - - - - \ No newline at end of file + + Mapsui logo diagonal gradient + + A purple circle with a cubic diagonal gradient and matching top and bottom cutouts. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appiconfg.svg b/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index 21dfb25f18..0000000000 --- a/Samples/Mapsui.Samples.Maui/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Samples/Mapsui.Samples.Maui/Resources/Splash/splash.svg b/Samples/Mapsui.Samples.Maui/Resources/Splash/splash.svg index 21dfb25f18..802dbc7c2f 100644 --- a/Samples/Mapsui.Samples.Maui/Resources/Splash/splash.svg +++ b/Samples/Mapsui.Samples.Maui/Resources/Splash/splash.svg @@ -1,8 +1,67 @@ - - - - - - - - \ No newline at end of file + + Mapsui logo diagonal gradient + + A purple circle with a cubic diagonal gradient and matching top and bottom cutouts. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples/Mapsui.Samples.Maui/Resources/Styles/Styles.xaml b/Samples/Mapsui.Samples.Maui/Resources/Styles/Styles.xaml index 3b0fdc4123..6b13e75d5c 100644 --- a/Samples/Mapsui.Samples.Maui/Resources/Styles/Styles.xaml +++ b/Samples/Mapsui.Samples.Maui/Resources/Styles/Styles.xaml @@ -24,7 +24,7 @@