Compare commits
22 Commits
v1.0.0-pre
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
7e58513ab3 | |
|
|
a450daa86f | |
|
|
c8840f2e8b | |
|
|
f0dbd29b58 | |
|
|
a4f04f4966 | |
|
|
a03c600864 | |
|
|
0c460c1395 | |
|
|
0dd7a2d3fb | |
|
|
afbf8f6782 | |
|
|
02b3da17d4 | |
|
|
299914d077 | |
|
|
ed09456d57 | |
|
|
1d55ac672a | |
|
|
f945d2a537 | |
|
|
1d9338d823 | |
|
|
ae5c9ab738 | |
|
|
d238dde5a4 | |
|
|
071df4eb28 | |
|
|
2535f16893 | |
|
|
924d0ae874 | |
|
|
7172907333 | |
|
|
a4d9d2c1eb |
|
|
@ -0,0 +1,37 @@
|
|||
# OpenMaui Linux CI/CD Pipeline for Gitea
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
DOTNET_ROOT: C:\dotnet
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore dependencies
|
||||
run: C:\dotnet\dotnet.exe restore
|
||||
|
||||
- name: Build
|
||||
run: C:\dotnet\dotnet.exe build --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
run: C:\dotnet\dotnet.exe test --configuration Release --no-build --verbosity normal
|
||||
continue-on-error: true
|
||||
|
||||
- name: Pack NuGet (preview)
|
||||
run: C:\dotnet\dotnet.exe pack --configuration Release --no-build -o ./nupkg
|
||||
|
||||
- name: List NuGet packages
|
||||
run: dir .\nupkg\
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# OpenMaui Linux Release - Publish to NuGet
|
||||
name: Release to NuGet
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
DOTNET_ROOT: C:\dotnet
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and Publish to NuGet
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ github.ref_name }}"
|
||||
$version = $tag -replace '^v', ''
|
||||
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
|
||||
echo "Building version: $version"
|
||||
|
||||
- name: Restore dependencies
|
||||
run: C:\dotnet\dotnet.exe restore
|
||||
|
||||
- name: Build
|
||||
run: C:\dotnet\dotnet.exe build --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
run: C:\dotnet\dotnet.exe test --configuration Release --no-build --verbosity normal
|
||||
|
||||
- name: Pack NuGet package
|
||||
run: C:\dotnet\dotnet.exe pack --configuration Release --no-build -o ./nupkg /p:PackageVersion=${{ steps.version.outputs.VERSION }}
|
||||
|
||||
- name: Publish to NuGet.org
|
||||
run: C:\dotnet\dotnet.exe nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
@ -42,14 +42,14 @@ jobs:
|
|||
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: '**/TestResults/*.trx'
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
run: dotnet pack --configuration Release --no-restore -o ./nupkg
|
||||
|
||||
- name: Upload NuGet package
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: nuget-package
|
||||
path: ./nupkg/*.nupkg
|
||||
|
|
@ -94,7 +94,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
run: dotnet pack --configuration Release --no-build -o ./nupkg /p:PackageVersion=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Upload NuGet package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: nuget-package-${{ steps.version.outputs.version }}
|
||||
path: ./nupkg/*.nupkg
|
||||
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
|
||||
- name: Attach packages to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./nupkg/*.nupkg
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Graphics;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Type converter for converting between MAUI Color and SKColor.
|
||||
/// Enables XAML styling with Color values that get applied to Skia controls.
|
||||
/// </summary>
|
||||
public class SKColorTypeConverter : TypeConverter
|
||||
{
|
||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||
{
|
||||
return sourceType == typeof(string) ||
|
||||
sourceType == typeof(Color) ||
|
||||
base.CanConvertFrom(context, sourceType);
|
||||
}
|
||||
|
||||
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
|
||||
{
|
||||
return destinationType == typeof(string) ||
|
||||
destinationType == typeof(Color) ||
|
||||
base.CanConvertTo(context, destinationType);
|
||||
}
|
||||
|
||||
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
|
||||
{
|
||||
if (value is Color mauiColor)
|
||||
{
|
||||
return ToSKColor(mauiColor);
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
{
|
||||
return ParseColor(str);
|
||||
}
|
||||
|
||||
return base.ConvertFrom(context, culture, value);
|
||||
}
|
||||
|
||||
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
|
||||
{
|
||||
if (value is SKColor skColor)
|
||||
{
|
||||
if (destinationType == typeof(string))
|
||||
{
|
||||
return $"#{skColor.Alpha:X2}{skColor.Red:X2}{skColor.Green:X2}{skColor.Blue:X2}";
|
||||
}
|
||||
|
||||
if (destinationType == typeof(Color))
|
||||
{
|
||||
return ToMauiColor(skColor);
|
||||
}
|
||||
}
|
||||
|
||||
return base.ConvertTo(context, culture, value, destinationType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Color to an SKColor.
|
||||
/// </summary>
|
||||
public static SKColor ToSKColor(Color mauiColor)
|
||||
{
|
||||
return new SKColor(
|
||||
(byte)(mauiColor.Red * 255),
|
||||
(byte)(mauiColor.Green * 255),
|
||||
(byte)(mauiColor.Blue * 255),
|
||||
(byte)(mauiColor.Alpha * 255));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an SKColor to a MAUI Color.
|
||||
/// </summary>
|
||||
public static Color ToMauiColor(SKColor skColor)
|
||||
{
|
||||
return new Color(
|
||||
skColor.Red / 255f,
|
||||
skColor.Green / 255f,
|
||||
skColor.Blue / 255f,
|
||||
skColor.Alpha / 255f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a color string (hex, named, or rgb format).
|
||||
/// </summary>
|
||||
private static SKColor ParseColor(string colorString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(colorString))
|
||||
return SKColors.Black;
|
||||
|
||||
colorString = colorString.Trim();
|
||||
|
||||
// Try hex format
|
||||
if (colorString.StartsWith("#"))
|
||||
{
|
||||
return SKColor.Parse(colorString);
|
||||
}
|
||||
|
||||
// Try named colors
|
||||
var namedColor = GetNamedColor(colorString.ToLowerInvariant());
|
||||
if (namedColor.HasValue)
|
||||
return namedColor.Value;
|
||||
|
||||
// Try rgb/rgba format
|
||||
if (colorString.StartsWith("rgb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ParseRgbColor(colorString);
|
||||
}
|
||||
|
||||
// Fallback to SKColor.Parse
|
||||
if (SKColor.TryParse(colorString, out var parsed))
|
||||
return parsed;
|
||||
|
||||
return SKColors.Black;
|
||||
}
|
||||
|
||||
private static SKColor? GetNamedColor(string name) => name switch
|
||||
{
|
||||
"transparent" => SKColors.Transparent,
|
||||
"black" => SKColors.Black,
|
||||
"white" => SKColors.White,
|
||||
"red" => SKColors.Red,
|
||||
"green" => SKColors.Green,
|
||||
"blue" => SKColors.Blue,
|
||||
"yellow" => SKColors.Yellow,
|
||||
"cyan" => SKColors.Cyan,
|
||||
"magenta" => SKColors.Magenta,
|
||||
"gray" or "grey" => SKColors.Gray,
|
||||
"darkgray" or "darkgrey" => SKColors.DarkGray,
|
||||
"lightgray" or "lightgrey" => SKColors.LightGray,
|
||||
"orange" => new SKColor(0xFF, 0xA5, 0x00),
|
||||
"pink" => new SKColor(0xFF, 0xC0, 0xCB),
|
||||
"purple" => new SKColor(0x80, 0x00, 0x80),
|
||||
"brown" => new SKColor(0xA5, 0x2A, 0x2A),
|
||||
"navy" => new SKColor(0x00, 0x00, 0x80),
|
||||
"teal" => new SKColor(0x00, 0x80, 0x80),
|
||||
"olive" => new SKColor(0x80, 0x80, 0x00),
|
||||
"silver" => new SKColor(0xC0, 0xC0, 0xC0),
|
||||
"maroon" => new SKColor(0x80, 0x00, 0x00),
|
||||
"lime" => new SKColor(0x00, 0xFF, 0x00),
|
||||
"aqua" => new SKColor(0x00, 0xFF, 0xFF),
|
||||
"fuchsia" => new SKColor(0xFF, 0x00, 0xFF),
|
||||
"gold" => new SKColor(0xFF, 0xD7, 0x00),
|
||||
"coral" => new SKColor(0xFF, 0x7F, 0x50),
|
||||
"salmon" => new SKColor(0xFA, 0x80, 0x72),
|
||||
"crimson" => new SKColor(0xDC, 0x14, 0x3C),
|
||||
"indigo" => new SKColor(0x4B, 0x00, 0x82),
|
||||
"violet" => new SKColor(0xEE, 0x82, 0xEE),
|
||||
"turquoise" => new SKColor(0x40, 0xE0, 0xD0),
|
||||
"tan" => new SKColor(0xD2, 0xB4, 0x8C),
|
||||
"chocolate" => new SKColor(0xD2, 0x69, 0x1E),
|
||||
"tomato" => new SKColor(0xFF, 0x63, 0x47),
|
||||
"steelblue" => new SKColor(0x46, 0x82, 0xB4),
|
||||
"skyblue" => new SKColor(0x87, 0xCE, 0xEB),
|
||||
"slategray" or "slategrey" => new SKColor(0x70, 0x80, 0x90),
|
||||
"seagreen" => new SKColor(0x2E, 0x8B, 0x57),
|
||||
"royalblue" => new SKColor(0x41, 0x69, 0xE1),
|
||||
"plum" => new SKColor(0xDD, 0xA0, 0xDD),
|
||||
"peru" => new SKColor(0xCD, 0x85, 0x3F),
|
||||
"orchid" => new SKColor(0xDA, 0x70, 0xD6),
|
||||
"orangered" => new SKColor(0xFF, 0x45, 0x00),
|
||||
"olivedrab" => new SKColor(0x6B, 0x8E, 0x23),
|
||||
"midnightblue" => new SKColor(0x19, 0x19, 0x70),
|
||||
"mediumblue" => new SKColor(0x00, 0x00, 0xCD),
|
||||
"limegreen" => new SKColor(0x32, 0xCD, 0x32),
|
||||
"hotpink" => new SKColor(0xFF, 0x69, 0xB4),
|
||||
"honeydew" => new SKColor(0xF0, 0xFF, 0xF0),
|
||||
"greenyellow" => new SKColor(0xAD, 0xFF, 0x2F),
|
||||
"forestgreen" => new SKColor(0x22, 0x8B, 0x22),
|
||||
"firebrick" => new SKColor(0xB2, 0x22, 0x22),
|
||||
"dodgerblue" => new SKColor(0x1E, 0x90, 0xFF),
|
||||
"deeppink" => new SKColor(0xFF, 0x14, 0x93),
|
||||
"deepskyblue" => new SKColor(0x00, 0xBF, 0xFF),
|
||||
"darkviolet" => new SKColor(0x94, 0x00, 0xD3),
|
||||
"darkturquoise" => new SKColor(0x00, 0xCE, 0xD1),
|
||||
"darkslategray" or "darkslategrey" => new SKColor(0x2F, 0x4F, 0x4F),
|
||||
"darkred" => new SKColor(0x8B, 0x00, 0x00),
|
||||
"darkorange" => new SKColor(0xFF, 0x8C, 0x00),
|
||||
"darkolivegreen" => new SKColor(0x55, 0x6B, 0x2F),
|
||||
"darkmagenta" => new SKColor(0x8B, 0x00, 0x8B),
|
||||
"darkkhaki" => new SKColor(0xBD, 0xB7, 0x6B),
|
||||
"darkgreen" => new SKColor(0x00, 0x64, 0x00),
|
||||
"darkgoldenrod" => new SKColor(0xB8, 0x86, 0x0B),
|
||||
"darkcyan" => new SKColor(0x00, 0x8B, 0x8B),
|
||||
"darkblue" => new SKColor(0x00, 0x00, 0x8B),
|
||||
"cornflowerblue" => new SKColor(0x64, 0x95, 0xED),
|
||||
"cadetblue" => new SKColor(0x5F, 0x9E, 0xA0),
|
||||
"blueviolet" => new SKColor(0x8A, 0x2B, 0xE2),
|
||||
"azure" => new SKColor(0xF0, 0xFF, 0xFF),
|
||||
"aquamarine" => new SKColor(0x7F, 0xFF, 0xD4),
|
||||
"aliceblue" => new SKColor(0xF0, 0xF8, 0xFF),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static SKColor ParseRgbColor(string colorString)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isRgba = colorString.StartsWith("rgba", StringComparison.OrdinalIgnoreCase);
|
||||
var startIndex = colorString.IndexOf('(');
|
||||
var endIndex = colorString.IndexOf(')');
|
||||
|
||||
if (startIndex == -1 || endIndex == -1)
|
||||
return SKColors.Black;
|
||||
|
||||
var values = colorString.Substring(startIndex + 1, endIndex - startIndex - 1)
|
||||
.Split(',')
|
||||
.Select(v => v.Trim())
|
||||
.ToArray();
|
||||
|
||||
if (values.Length < 3)
|
||||
return SKColors.Black;
|
||||
|
||||
var r = byte.Parse(values[0]);
|
||||
var g = byte.Parse(values[1]);
|
||||
var b = byte.Parse(values[2]);
|
||||
byte a = 255;
|
||||
|
||||
if (isRgba && values.Length >= 4)
|
||||
{
|
||||
var alphaValue = float.Parse(values[3], CultureInfo.InvariantCulture);
|
||||
a = (byte)(alphaValue <= 1 ? alphaValue * 255 : alphaValue);
|
||||
}
|
||||
|
||||
return new SKColor(r, g, b, a);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return SKColors.Black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for color conversion.
|
||||
/// </summary>
|
||||
public static class ColorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a MAUI Color to an SKColor.
|
||||
/// </summary>
|
||||
public static SKColor ToSKColor(this Color color)
|
||||
{
|
||||
return SKColorTypeConverter.ToSKColor(color);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an SKColor to a MAUI Color.
|
||||
/// </summary>
|
||||
public static Color ToMauiColor(this SKColor color)
|
||||
{
|
||||
return SKColorTypeConverter.ToMauiColor(color);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Type converter for converting between MAUI Thickness and SKRect (for padding/margin).
|
||||
/// Enables XAML styling with Thickness values that get applied to Skia controls.
|
||||
/// </summary>
|
||||
public class SKRectTypeConverter : TypeConverter
|
||||
{
|
||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||
{
|
||||
return sourceType == typeof(string) ||
|
||||
sourceType == typeof(Thickness) ||
|
||||
sourceType == typeof(double) ||
|
||||
sourceType == typeof(float) ||
|
||||
base.CanConvertFrom(context, sourceType);
|
||||
}
|
||||
|
||||
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
|
||||
{
|
||||
return destinationType == typeof(string) ||
|
||||
destinationType == typeof(Thickness) ||
|
||||
base.CanConvertTo(context, destinationType);
|
||||
}
|
||||
|
||||
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
|
||||
{
|
||||
if (value is Thickness thickness)
|
||||
{
|
||||
return ThicknessToSKRect(thickness);
|
||||
}
|
||||
|
||||
if (value is double d)
|
||||
{
|
||||
return new SKRect((float)d, (float)d, (float)d, (float)d);
|
||||
}
|
||||
|
||||
if (value is float f)
|
||||
{
|
||||
return new SKRect(f, f, f, f);
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
{
|
||||
return ParseRect(str);
|
||||
}
|
||||
|
||||
return base.ConvertFrom(context, culture, value);
|
||||
}
|
||||
|
||||
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
|
||||
{
|
||||
if (value is SKRect rect)
|
||||
{
|
||||
if (destinationType == typeof(string))
|
||||
{
|
||||
return $"{rect.Left},{rect.Top},{rect.Right},{rect.Bottom}";
|
||||
}
|
||||
|
||||
if (destinationType == typeof(Thickness))
|
||||
{
|
||||
return SKRectToThickness(rect);
|
||||
}
|
||||
}
|
||||
|
||||
return base.ConvertTo(context, culture, value, destinationType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Thickness to an SKRect (used as padding storage).
|
||||
/// </summary>
|
||||
public static SKRect ThicknessToSKRect(Thickness thickness)
|
||||
{
|
||||
return new SKRect(
|
||||
(float)thickness.Left,
|
||||
(float)thickness.Top,
|
||||
(float)thickness.Right,
|
||||
(float)thickness.Bottom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an SKRect (used as padding storage) to a MAUI Thickness.
|
||||
/// </summary>
|
||||
public static Thickness SKRectToThickness(SKRect rect)
|
||||
{
|
||||
return new Thickness(rect.Left, rect.Top, rect.Right, rect.Bottom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a string into an SKRect for padding/margin.
|
||||
/// Supports formats: "uniform", "horizontal,vertical", "left,top,right,bottom"
|
||||
/// </summary>
|
||||
private static SKRect ParseRect(string str)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
return SKRect.Empty;
|
||||
|
||||
str = str.Trim();
|
||||
var parts = str.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
// Uniform padding
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var uniform))
|
||||
{
|
||||
return new SKRect(uniform, uniform, uniform, uniform);
|
||||
}
|
||||
}
|
||||
else if (parts.Length == 2)
|
||||
{
|
||||
// Horizontal, Vertical
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var horizontal) &&
|
||||
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var vertical))
|
||||
{
|
||||
return new SKRect(horizontal, vertical, horizontal, vertical);
|
||||
}
|
||||
}
|
||||
else if (parts.Length == 4)
|
||||
{
|
||||
// Left, Top, Right, Bottom
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var left) &&
|
||||
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var top) &&
|
||||
float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var right) &&
|
||||
float.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var bottom))
|
||||
{
|
||||
return new SKRect(left, top, right, bottom);
|
||||
}
|
||||
}
|
||||
|
||||
return SKRect.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type converter for SKSize.
|
||||
/// </summary>
|
||||
public class SKSizeTypeConverter : TypeConverter
|
||||
{
|
||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||
{
|
||||
return sourceType == typeof(string) ||
|
||||
sourceType == typeof(Size) ||
|
||||
base.CanConvertFrom(context, sourceType);
|
||||
}
|
||||
|
||||
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
|
||||
{
|
||||
return destinationType == typeof(string) ||
|
||||
destinationType == typeof(Size) ||
|
||||
base.CanConvertTo(context, destinationType);
|
||||
}
|
||||
|
||||
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
|
||||
{
|
||||
if (value is Size size)
|
||||
{
|
||||
return new SKSize((float)size.Width, (float)size.Height);
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
{
|
||||
return ParseSize(str);
|
||||
}
|
||||
|
||||
return base.ConvertFrom(context, culture, value);
|
||||
}
|
||||
|
||||
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
|
||||
{
|
||||
if (value is SKSize size)
|
||||
{
|
||||
if (destinationType == typeof(string))
|
||||
{
|
||||
return $"{size.Width},{size.Height}";
|
||||
}
|
||||
|
||||
if (destinationType == typeof(Size))
|
||||
{
|
||||
return new Size(size.Width, size.Height);
|
||||
}
|
||||
}
|
||||
|
||||
return base.ConvertTo(context, culture, value, destinationType);
|
||||
}
|
||||
|
||||
private static SKSize ParseSize(string str)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
return SKSize.Empty;
|
||||
|
||||
str = str.Trim();
|
||||
var parts = str.Split(new[] { ',', ' ', 'x', 'X' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var uniform))
|
||||
{
|
||||
return new SKSize(uniform, uniform);
|
||||
}
|
||||
}
|
||||
else if (parts.Length == 2)
|
||||
{
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var width) &&
|
||||
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var height))
|
||||
{
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
return SKSize.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type converter for SKPoint.
|
||||
/// </summary>
|
||||
public class SKPointTypeConverter : TypeConverter
|
||||
{
|
||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||
{
|
||||
return sourceType == typeof(string) ||
|
||||
sourceType == typeof(Point) ||
|
||||
base.CanConvertFrom(context, sourceType);
|
||||
}
|
||||
|
||||
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
|
||||
{
|
||||
return destinationType == typeof(string) ||
|
||||
destinationType == typeof(Point) ||
|
||||
base.CanConvertTo(context, destinationType);
|
||||
}
|
||||
|
||||
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
|
||||
{
|
||||
if (value is Point point)
|
||||
{
|
||||
return new SKPoint((float)point.X, (float)point.Y);
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
{
|
||||
return ParsePoint(str);
|
||||
}
|
||||
|
||||
return base.ConvertFrom(context, culture, value);
|
||||
}
|
||||
|
||||
public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
|
||||
{
|
||||
if (value is SKPoint point)
|
||||
{
|
||||
if (destinationType == typeof(string))
|
||||
{
|
||||
return $"{point.X},{point.Y}";
|
||||
}
|
||||
|
||||
if (destinationType == typeof(Point))
|
||||
{
|
||||
return new Point(point.X, point.Y);
|
||||
}
|
||||
}
|
||||
|
||||
return base.ConvertTo(context, culture, value, destinationType);
|
||||
}
|
||||
|
||||
private static SKPoint ParsePoint(string str)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
return SKPoint.Empty;
|
||||
|
||||
str = str.Trim();
|
||||
var parts = str.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) &&
|
||||
float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||
{
|
||||
return new SKPoint(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
return SKPoint.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for SkiaSharp type conversions.
|
||||
/// </summary>
|
||||
public static class SKTypeExtensions
|
||||
{
|
||||
public static SKRect ToSKRect(this Thickness thickness)
|
||||
{
|
||||
return SKRectTypeConverter.ThicknessToSKRect(thickness);
|
||||
}
|
||||
|
||||
public static Thickness ToThickness(this SKRect rect)
|
||||
{
|
||||
return SKRectTypeConverter.SKRectToThickness(rect);
|
||||
}
|
||||
|
||||
public static SKSize ToSKSize(this Size size)
|
||||
{
|
||||
return new SKSize((float)size.Width, (float)size.Height);
|
||||
}
|
||||
|
||||
public static Size ToSize(this SKSize size)
|
||||
{
|
||||
return new Size(size.Width, size.Height);
|
||||
}
|
||||
|
||||
public static SKPoint ToSKPoint(this Point point)
|
||||
{
|
||||
return new SKPoint((float)point.X, (float)point.Y);
|
||||
}
|
||||
|
||||
public static Point ToPoint(this SKPoint point)
|
||||
{
|
||||
return new Point(point.X, point.Y);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ public partial class ActivityIndicatorHandler : ViewHandler<IActivityIndicator,
|
|||
[nameof(IActivityIndicator.IsRunning)] = MapIsRunning,
|
||||
[nameof(IActivityIndicator.Color)] = MapColor,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<IActivityIndicator, ActivityIndicatorHandler> CommandMapper = new(ViewHandler.ViewCommandMapper);
|
||||
|
|
@ -40,4 +42,22 @@ public partial class ActivityIndicatorHandler : ViewHandler<IActivityIndicator,
|
|||
handler.PlatformView.IsEnabled = activityIndicator.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(ActivityIndicatorHandler handler, IActivityIndicator activityIndicator)
|
||||
{
|
||||
if (activityIndicator.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(ActivityIndicatorHandler handler, IActivityIndicator activityIndicator)
|
||||
{
|
||||
if (activityIndicator is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Platform.Linux.Hosting;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for MAUI Application on Linux.
|
||||
/// Bridges the MAUI Application lifecycle with LinuxApplication.
|
||||
/// </summary>
|
||||
public partial class ApplicationHandler : ElementHandler<IApplication, LinuxApplicationContext>
|
||||
{
|
||||
public static IPropertyMapper<IApplication, ApplicationHandler> Mapper =
|
||||
new PropertyMapper<IApplication, ApplicationHandler>(ElementHandler.ElementMapper)
|
||||
{
|
||||
};
|
||||
|
||||
public static CommandMapper<IApplication, ApplicationHandler> CommandMapper =
|
||||
new(ElementHandler.ElementCommandMapper)
|
||||
{
|
||||
[nameof(IApplication.OpenWindow)] = MapOpenWindow,
|
||||
[nameof(IApplication.CloseWindow)] = MapCloseWindow,
|
||||
};
|
||||
|
||||
public ApplicationHandler() : base(Mapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
public ApplicationHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null)
|
||||
: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override LinuxApplicationContext CreatePlatformElement()
|
||||
{
|
||||
return new LinuxApplicationContext();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(LinuxApplicationContext platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
platformView.Application = VirtualView;
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(LinuxApplicationContext platformView)
|
||||
{
|
||||
platformView.Application = null;
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
public static void MapOpenWindow(ApplicationHandler handler, IApplication application, object? args)
|
||||
{
|
||||
if (args is IWindow window)
|
||||
{
|
||||
handler.PlatformView?.OpenWindow(window);
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapCloseWindow(ApplicationHandler handler, IApplication application, object? args)
|
||||
{
|
||||
if (args is IWindow window)
|
||||
{
|
||||
handler.PlatformView?.CloseWindow(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform context for the MAUI Application on Linux.
|
||||
/// Manages windows and the application lifecycle.
|
||||
/// </summary>
|
||||
public class LinuxApplicationContext
|
||||
{
|
||||
private readonly List<IWindow> _windows = new();
|
||||
private IApplication? _application;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MAUI Application.
|
||||
/// </summary>
|
||||
public IApplication? Application
|
||||
{
|
||||
get => _application;
|
||||
set
|
||||
{
|
||||
_application = value;
|
||||
if (_application != null)
|
||||
{
|
||||
// Initialize windows from the application
|
||||
foreach (var window in _application.Windows)
|
||||
{
|
||||
if (!_windows.Contains(window))
|
||||
{
|
||||
_windows.Add(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of open windows.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IWindow> Windows => _windows;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a window and creates its handler.
|
||||
/// </summary>
|
||||
public void OpenWindow(IWindow window)
|
||||
{
|
||||
if (!_windows.Contains(window))
|
||||
{
|
||||
_windows.Add(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a window and cleans up its handler.
|
||||
/// </summary>
|
||||
public void CloseWindow(IWindow window)
|
||||
{
|
||||
_windows.Remove(window);
|
||||
|
||||
if (_windows.Count == 0)
|
||||
{
|
||||
// Last window closed, stop the application
|
||||
LinuxApplication.Current?.MainWindow?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the main window of the application.
|
||||
/// </summary>
|
||||
public IWindow? MainWindow => _windows.Count > 0 ? _windows[0] : null;
|
||||
}
|
||||
|
|
@ -20,7 +20,9 @@ public partial class BorderHandler : ViewHandler<IBorderView, SkiaBorder>
|
|||
[nameof(IBorderView.Content)] = MapContent,
|
||||
[nameof(IBorderStroke.Stroke)] = MapStroke,
|
||||
[nameof(IBorderStroke.StrokeThickness)] = MapStrokeThickness,
|
||||
["StrokeShape"] = MapStrokeShape, // StrokeShape is on Border, not IBorderStroke
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
[nameof(IPadding.Padding)] = MapPadding,
|
||||
};
|
||||
|
||||
|
|
@ -55,13 +57,25 @@ public partial class BorderHandler : ViewHandler<IBorderView, SkiaBorder>
|
|||
|
||||
public static void MapContent(BorderHandler handler, IBorderView border)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
if (handler.PlatformView is null || handler.MauiContext is null) return;
|
||||
|
||||
handler.PlatformView.ClearChildren();
|
||||
|
||||
if (border.PresentedContent?.Handler?.PlatformView is SkiaView skiaContent)
|
||||
var content = border.PresentedContent;
|
||||
if (content != null)
|
||||
{
|
||||
handler.PlatformView.AddChild(skiaContent);
|
||||
// Create handler for content if it doesn't exist
|
||||
if (content.Handler == null)
|
||||
{
|
||||
Console.WriteLine($"[BorderHandler] Creating handler for content: {content.GetType().Name}");
|
||||
content.Handler = content.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (content.Handler?.PlatformView is SkiaView skiaContent)
|
||||
{
|
||||
Console.WriteLine($"[BorderHandler] Adding content: {skiaContent.GetType().Name}");
|
||||
handler.PlatformView.AddChild(skiaContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +105,17 @@ public partial class BorderHandler : ViewHandler<IBorderView, SkiaBorder>
|
|||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(BorderHandler handler, IBorderView border)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
if (border is VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapPadding(BorderHandler handler, IBorderView border)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
|
@ -101,4 +126,33 @@ public partial class BorderHandler : ViewHandler<IBorderView, SkiaBorder>
|
|||
handler.PlatformView.PaddingRight = (float)padding.Right;
|
||||
handler.PlatformView.PaddingBottom = (float)padding.Bottom;
|
||||
}
|
||||
|
||||
public static void MapStrokeShape(BorderHandler handler, IBorderView border)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
// StrokeShape is on the Border control class, not IBorderView interface
|
||||
if (border is not Border borderControl) return;
|
||||
|
||||
var shape = borderControl.StrokeShape;
|
||||
if (shape is Microsoft.Maui.Controls.Shapes.RoundRectangle roundRect)
|
||||
{
|
||||
// RoundRectangle can have different corner radii, but we use a uniform one
|
||||
// Take the top-left corner as the uniform radius
|
||||
var cornerRadius = roundRect.CornerRadius;
|
||||
handler.PlatformView.CornerRadius = (float)cornerRadius.TopLeft;
|
||||
}
|
||||
else if (shape is Microsoft.Maui.Controls.Shapes.Rectangle)
|
||||
{
|
||||
handler.PlatformView.CornerRadius = 0;
|
||||
}
|
||||
else if (shape is Microsoft.Maui.Controls.Shapes.Ellipse)
|
||||
{
|
||||
// For ellipse, use half the min dimension as corner radius
|
||||
// This will be applied during rendering when bounds are known
|
||||
handler.PlatformView.CornerRadius = float.MaxValue; // Marker for "fully rounded"
|
||||
}
|
||||
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for BoxView on Linux.
|
||||
/// </summary>
|
||||
public partial class BoxViewHandler : ViewHandler<BoxView, SkiaBoxView>
|
||||
{
|
||||
public static IPropertyMapper<BoxView, BoxViewHandler> Mapper =
|
||||
new PropertyMapper<BoxView, BoxViewHandler>(ViewMapper)
|
||||
{
|
||||
[nameof(BoxView.Color)] = MapColor,
|
||||
[nameof(BoxView.CornerRadius)] = MapCornerRadius,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public BoxViewHandler() : base(Mapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override SkiaBoxView CreatePlatformView()
|
||||
{
|
||||
return new SkiaBoxView();
|
||||
}
|
||||
|
||||
public static void MapColor(BoxViewHandler handler, BoxView boxView)
|
||||
{
|
||||
if (boxView.Color != null)
|
||||
{
|
||||
handler.PlatformView.Color = new SKColor(
|
||||
(byte)(boxView.Color.Red * 255),
|
||||
(byte)(boxView.Color.Green * 255),
|
||||
(byte)(boxView.Color.Blue * 255),
|
||||
(byte)(boxView.Color.Alpha * 255));
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapCornerRadius(BoxViewHandler handler, BoxView boxView)
|
||||
{
|
||||
handler.PlatformView.CornerRadius = (float)boxView.CornerRadius.TopLeft;
|
||||
}
|
||||
|
||||
public static void MapBackground(BoxViewHandler handler, BoxView boxView)
|
||||
{
|
||||
if (boxView.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(BoxViewHandler handler, BoxView boxView)
|
||||
{
|
||||
if (boxView.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = boxView.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,21 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
platformView.Clicked += OnClicked;
|
||||
platformView.Pressed += OnPressed;
|
||||
platformView.Released += OnReleased;
|
||||
|
||||
// Manually map all properties on connect since MAUI may not trigger updates
|
||||
// for properties that were set before handler connection
|
||||
if (VirtualView != null)
|
||||
{
|
||||
MapText(this, VirtualView);
|
||||
MapTextColor(this, VirtualView);
|
||||
MapBackground(this, VirtualView);
|
||||
MapFont(this, VirtualView);
|
||||
MapPadding(this, VirtualView);
|
||||
MapCornerRadius(this, VirtualView);
|
||||
MapBorderColor(this, VirtualView);
|
||||
MapBorderWidth(this, VirtualView);
|
||||
MapIsEnabled(this, VirtualView);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaButton platformView)
|
||||
|
|
@ -105,7 +120,8 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
var background = button.Background;
|
||||
if (background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
// Use ButtonBackgroundColor which is used for rendering, not base BackgroundColor
|
||||
handler.PlatformView.ButtonBackgroundColor = solidBrush.Color.ToSKColor();
|
||||
}
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
|
@ -156,6 +172,7 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
|
||||
public static void MapIsEnabled(ButtonHandler handler, IButton button)
|
||||
{
|
||||
Console.WriteLine($"[ButtonHandler] MapIsEnabled called - Text='{handler.PlatformView.Text}', IsEnabled={button.IsEnabled}");
|
||||
handler.PlatformView.IsEnabled = button.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
[nameof(IButtonStroke.CornerRadius)] = MapCornerRadius,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(IPadding.Padding)] = MapPadding,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
};
|
||||
|
||||
public static CommandMapper<IButton, ButtonHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
|
|
@ -47,6 +48,18 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
platformView.Clicked += OnClicked;
|
||||
platformView.Pressed += OnPressed;
|
||||
platformView.Released += OnReleased;
|
||||
|
||||
// Manually map all properties on connect since MAUI may not trigger updates
|
||||
// for properties that were set before handler connection
|
||||
if (VirtualView != null)
|
||||
{
|
||||
MapStrokeColor(this, VirtualView);
|
||||
MapStrokeThickness(this, VirtualView);
|
||||
MapCornerRadius(this, VirtualView);
|
||||
MapBackground(this, VirtualView);
|
||||
MapPadding(this, VirtualView);
|
||||
MapIsEnabled(this, VirtualView);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaButton platformView)
|
||||
|
|
@ -88,7 +101,8 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
|
||||
if (button.Background is SolidPaint solidPaint && solidPaint.Color is not null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
// Set ButtonBackgroundColor (used for rendering) not base BackgroundColor
|
||||
handler.PlatformView.ButtonBackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +117,14 @@ public partial class ButtonHandler : ViewHandler<IButton, SkiaButton>
|
|||
(float)padding.Right,
|
||||
(float)padding.Bottom);
|
||||
}
|
||||
|
||||
public static void MapIsEnabled(ButtonHandler handler, IButton button)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
Console.WriteLine($"[ButtonHandler] MapIsEnabled - Text='{handler.PlatformView.Text}', IsEnabled={button.IsEnabled}");
|
||||
handler.PlatformView.IsEnabled = button.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -124,6 +146,21 @@ public partial class TextButtonHandler : ButtonHandler
|
|||
{
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaButton platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
// Manually map text properties on connect since MAUI may not trigger updates
|
||||
// for properties that were set before handler connection
|
||||
if (VirtualView is ITextButton textButton)
|
||||
{
|
||||
MapText(this, textButton);
|
||||
MapTextColor(this, textButton);
|
||||
MapFont(this, textButton);
|
||||
MapCharacterSpacing(this, textButton);
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapText(TextButtonHandler handler, ITextButton button)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public partial class CheckBoxHandler : ViewHandler<ICheckBox, SkiaCheckBox>
|
|||
[nameof(ICheckBox.IsChecked)] = MapIsChecked,
|
||||
[nameof(ICheckBox.Foreground)] = MapForeground,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -90,4 +92,22 @@ public partial class CheckBoxHandler : ViewHandler<ICheckBox, SkiaCheckBox>
|
|||
handler.PlatformView.IsEnabled = checkBox.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(CheckBoxHandler handler, ICheckBox checkBox)
|
||||
{
|
||||
if (checkBox.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(CheckBoxHandler handler, ICheckBox checkBox)
|
||||
{
|
||||
if (checkBox is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public partial class CheckBoxHandler : ViewHandler<ICheckBox, SkiaCheckBox>
|
|||
[nameof(ICheckBox.IsChecked)] = MapIsChecked,
|
||||
[nameof(ICheckBox.Foreground)] = MapForeground,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(IView.VerticalLayoutAlignment)] = MapVerticalLayoutAlignment,
|
||||
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
|
||||
};
|
||||
|
||||
public static CommandMapper<ICheckBox, CheckBoxHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
|
|
@ -83,4 +85,32 @@ public partial class CheckBoxHandler : ViewHandler<ICheckBox, SkiaCheckBox>
|
|||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapVerticalLayoutAlignment(CheckBoxHandler handler, ICheckBox checkBox)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
handler.PlatformView.VerticalOptions = checkBox.VerticalLayoutAlignment switch
|
||||
{
|
||||
Primitives.LayoutAlignment.Start => LayoutOptions.Start,
|
||||
Primitives.LayoutAlignment.Center => LayoutOptions.Center,
|
||||
Primitives.LayoutAlignment.End => LayoutOptions.End,
|
||||
Primitives.LayoutAlignment.Fill => LayoutOptions.Fill,
|
||||
_ => LayoutOptions.Fill
|
||||
};
|
||||
}
|
||||
|
||||
public static void MapHorizontalLayoutAlignment(CheckBoxHandler handler, ICheckBox checkBox)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
handler.PlatformView.HorizontalOptions = checkBox.HorizontalLayoutAlignment switch
|
||||
{
|
||||
Primitives.LayoutAlignment.Start => LayoutOptions.Start,
|
||||
Primitives.LayoutAlignment.Center => LayoutOptions.Center,
|
||||
Primitives.LayoutAlignment.End => LayoutOptions.End,
|
||||
Primitives.LayoutAlignment.Fill => LayoutOptions.Fill,
|
||||
_ => LayoutOptions.Start
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ namespace Microsoft.Maui.Platform.Linux.Handlers;
|
|||
/// </summary>
|
||||
public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCollectionView>
|
||||
{
|
||||
private bool _isUpdatingSelection;
|
||||
|
||||
public static IPropertyMapper<CollectionView, CollectionViewHandler> Mapper =
|
||||
new PropertyMapper<CollectionView, CollectionViewHandler>(ViewHandler.ViewMapper)
|
||||
{
|
||||
|
|
@ -36,6 +38,7 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
[nameof(StructuredItemsView.ItemsLayout)] = MapItemsLayout,
|
||||
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(CollectionView.BackgroundColor)] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<CollectionView, CollectionViewHandler> CommandMapper =
|
||||
|
|
@ -76,21 +79,34 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
|
||||
private void OnSelectionChanged(object? sender, CollectionSelectionChangedEventArgs e)
|
||||
{
|
||||
if (VirtualView is null) return;
|
||||
if (VirtualView is null || _isUpdatingSelection) return;
|
||||
|
||||
// Update virtual view selection
|
||||
if (VirtualView.SelectionMode == SelectionMode.Single)
|
||||
try
|
||||
{
|
||||
VirtualView.SelectedItem = e.CurrentSelection.FirstOrDefault();
|
||||
}
|
||||
else if (VirtualView.SelectionMode == SelectionMode.Multiple)
|
||||
{
|
||||
// Clear and update selected items
|
||||
VirtualView.SelectedItems.Clear();
|
||||
foreach (var item in e.CurrentSelection)
|
||||
_isUpdatingSelection = true;
|
||||
|
||||
// Update virtual view selection
|
||||
if (VirtualView.SelectionMode == SelectionMode.Single)
|
||||
{
|
||||
VirtualView.SelectedItems.Add(item);
|
||||
var newItem = e.CurrentSelection.FirstOrDefault();
|
||||
if (!Equals(VirtualView.SelectedItem, newItem))
|
||||
{
|
||||
VirtualView.SelectedItem = newItem;
|
||||
}
|
||||
}
|
||||
else if (VirtualView.SelectionMode == SelectionMode.Multiple)
|
||||
{
|
||||
// Clear and update selected items
|
||||
VirtualView.SelectedItems.Clear();
|
||||
foreach (var item in e.CurrentSelection)
|
||||
{
|
||||
VirtualView.SelectedItems.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +134,65 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
|
||||
public static void MapItemTemplate(CollectionViewHandler handler, CollectionView collectionView)
|
||||
{
|
||||
handler.PlatformView?.Invalidate();
|
||||
if (handler.PlatformView is null || handler.MauiContext is null) return;
|
||||
|
||||
var template = collectionView.ItemTemplate;
|
||||
if (template != null)
|
||||
{
|
||||
// Set up a renderer that creates views from the DataTemplate
|
||||
handler.PlatformView.ItemViewCreator = (item) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create view from template
|
||||
var content = template.CreateContent();
|
||||
if (content is View view)
|
||||
{
|
||||
// Set binding context FIRST so bindings evaluate
|
||||
view.BindingContext = item;
|
||||
|
||||
// Force binding evaluation by accessing the visual tree
|
||||
// This ensures child bindings are evaluated before handler creation
|
||||
PropagateBindingContext(view, item);
|
||||
|
||||
// Create handler for the view
|
||||
if (view.Handler == null && handler.MauiContext != null)
|
||||
{
|
||||
view.Handler = view.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (view.Handler?.PlatformView is SkiaView skiaView)
|
||||
{
|
||||
return skiaView;
|
||||
}
|
||||
}
|
||||
else if (content is ViewCell cell)
|
||||
{
|
||||
cell.BindingContext = item;
|
||||
var cellView = cell.View;
|
||||
if (cellView != null)
|
||||
{
|
||||
if (cellView.Handler == null && handler.MauiContext != null)
|
||||
{
|
||||
cellView.Handler = cellView.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (cellView.Handler?.PlatformView is SkiaView skiaView)
|
||||
{
|
||||
return skiaView;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore template creation errors
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapEmptyView(CollectionViewHandler handler, CollectionView collectionView)
|
||||
|
|
@ -146,19 +220,40 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
|
||||
public static void MapSelectedItem(CollectionViewHandler handler, CollectionView collectionView)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
handler.PlatformView.SelectedItem = collectionView.SelectedItem;
|
||||
if (handler.PlatformView is null || handler._isUpdatingSelection) return;
|
||||
|
||||
try
|
||||
{
|
||||
handler._isUpdatingSelection = true;
|
||||
if (!Equals(handler.PlatformView.SelectedItem, collectionView.SelectedItem))
|
||||
{
|
||||
handler.PlatformView.SelectedItem = collectionView.SelectedItem;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
handler._isUpdatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapSelectedItems(CollectionViewHandler handler, CollectionView collectionView)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
if (handler.PlatformView is null || handler._isUpdatingSelection) return;
|
||||
|
||||
// Sync selected items
|
||||
var selectedItems = collectionView.SelectedItems;
|
||||
if (selectedItems != null && selectedItems.Count > 0)
|
||||
try
|
||||
{
|
||||
handler.PlatformView.SelectedItem = selectedItems.First();
|
||||
handler._isUpdatingSelection = true;
|
||||
|
||||
// Sync selected items
|
||||
var selectedItems = collectionView.SelectedItems;
|
||||
if (selectedItems != null && selectedItems.Count > 0)
|
||||
{
|
||||
handler.PlatformView.SelectedItem = selectedItems.First();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
handler._isUpdatingSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,12 +309,26 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
// Don't override if BackgroundColor is explicitly set
|
||||
if (collectionView.BackgroundColor is not null)
|
||||
return;
|
||||
|
||||
if (collectionView.Background is SolidColorBrush solidBrush)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(CollectionViewHandler handler, CollectionView collectionView)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
if (collectionView.BackgroundColor is not null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = collectionView.BackgroundColor.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapScrollTo(CollectionViewHandler handler, CollectionView collectionView, object? args)
|
||||
{
|
||||
if (handler.PlatformView is null || args is not ScrollToRequestEventArgs scrollArgs)
|
||||
|
|
@ -234,4 +343,32 @@ public partial class CollectionViewHandler : ViewHandler<CollectionView, SkiaCol
|
|||
handler.PlatformView.ScrollToItem(scrollArgs.Item, scrollArgs.IsAnimated);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively propagates binding context to all child views to force binding evaluation.
|
||||
/// </summary>
|
||||
private static void PropagateBindingContext(View view, object? bindingContext)
|
||||
{
|
||||
view.BindingContext = bindingContext;
|
||||
|
||||
// Propagate to children
|
||||
if (view is Layout layout)
|
||||
{
|
||||
foreach (var child in layout.Children)
|
||||
{
|
||||
if (child is View childView)
|
||||
{
|
||||
PropagateBindingContext(childView, bindingContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (view is ContentView contentView && contentView.Content != null)
|
||||
{
|
||||
PropagateBindingContext(contentView.Content, bindingContext);
|
||||
}
|
||||
else if (view is Border border && border.Content is View borderContent)
|
||||
{
|
||||
PropagateBindingContext(borderContent, bindingContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public partial class EditorHandler : ViewHandler<IEditor, SkiaEditor>
|
|||
[nameof(IEditor.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
|
||||
[nameof(IEditor.VerticalTextAlignment)] = MapVerticalTextAlignment,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<IEditor, EditorHandler> CommandMapper =
|
||||
|
|
@ -82,6 +83,7 @@ public partial class EditorHandler : ViewHandler<IEditor, SkiaEditor>
|
|||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
handler.PlatformView.Text = editor.Text ?? "";
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapPlaceholder(EditorHandler handler, IEditor editor)
|
||||
|
|
@ -165,4 +167,15 @@ public partial class EditorHandler : ViewHandler<IEditor, SkiaEditor>
|
|||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(EditorHandler handler, IEditor editor)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
if (editor is VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public partial class EntryHandler : ViewHandler<IEntry, SkiaEntry>
|
|||
[nameof(IEntry.ReturnType)] = MapReturnType,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IEntry.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -186,4 +187,13 @@ public partial class EntryHandler : ViewHandler<IEntry, SkiaEntry>
|
|||
}
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(EntryHandler handler, IEntry entry)
|
||||
{
|
||||
if (entry is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,10 @@ public partial class EntryHandler : ViewHandler<IEntry, SkiaEntry>
|
|||
if (handler.PlatformView is null) return;
|
||||
|
||||
if (handler.PlatformView.Text != entry.Text)
|
||||
{
|
||||
handler.PlatformView.Text = entry.Text ?? string.Empty;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapTextColor(EntryHandler handler, IEntry entry)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for Frame on Linux using SkiaFrame.
|
||||
/// </summary>
|
||||
public partial class FrameHandler : ViewHandler<Frame, SkiaFrame>
|
||||
{
|
||||
public static IPropertyMapper<Frame, FrameHandler> Mapper =
|
||||
new PropertyMapper<Frame, FrameHandler>(ViewMapper)
|
||||
{
|
||||
[nameof(Frame.BorderColor)] = MapBorderColor,
|
||||
[nameof(Frame.CornerRadius)] = MapCornerRadius,
|
||||
[nameof(Frame.HasShadow)] = MapHasShadow,
|
||||
[nameof(Frame.BackgroundColor)] = MapBackgroundColor,
|
||||
[nameof(Frame.Padding)] = MapPadding,
|
||||
[nameof(Frame.Content)] = MapContent,
|
||||
};
|
||||
|
||||
public FrameHandler() : base(Mapper)
|
||||
{
|
||||
}
|
||||
|
||||
public FrameHandler(IPropertyMapper? mapper)
|
||||
: base(mapper ?? Mapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override SkiaFrame CreatePlatformView()
|
||||
{
|
||||
return new SkiaFrame();
|
||||
}
|
||||
|
||||
public static void MapBorderColor(FrameHandler handler, Frame frame)
|
||||
{
|
||||
if (frame.BorderColor != null)
|
||||
{
|
||||
handler.PlatformView.Stroke = new SKColor(
|
||||
(byte)(frame.BorderColor.Red * 255),
|
||||
(byte)(frame.BorderColor.Green * 255),
|
||||
(byte)(frame.BorderColor.Blue * 255),
|
||||
(byte)(frame.BorderColor.Alpha * 255));
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapCornerRadius(FrameHandler handler, Frame frame)
|
||||
{
|
||||
handler.PlatformView.CornerRadius = frame.CornerRadius;
|
||||
}
|
||||
|
||||
public static void MapHasShadow(FrameHandler handler, Frame frame)
|
||||
{
|
||||
handler.PlatformView.HasShadow = frame.HasShadow;
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(FrameHandler handler, Frame frame)
|
||||
{
|
||||
if (frame.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = new SKColor(
|
||||
(byte)(frame.BackgroundColor.Red * 255),
|
||||
(byte)(frame.BackgroundColor.Green * 255),
|
||||
(byte)(frame.BackgroundColor.Blue * 255),
|
||||
(byte)(frame.BackgroundColor.Alpha * 255));
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapPadding(FrameHandler handler, Frame frame)
|
||||
{
|
||||
handler.PlatformView.SetPadding(
|
||||
(float)frame.Padding.Left,
|
||||
(float)frame.Padding.Top,
|
||||
(float)frame.Padding.Right,
|
||||
(float)frame.Padding.Bottom);
|
||||
}
|
||||
|
||||
public static void MapContent(FrameHandler handler, Frame frame)
|
||||
{
|
||||
if (handler.PlatformView is null || handler.MauiContext is null) return;
|
||||
|
||||
handler.PlatformView.ClearChildren();
|
||||
|
||||
var content = frame.Content;
|
||||
if (content != null)
|
||||
{
|
||||
// Create handler for content if it doesn't exist
|
||||
if (content.Handler == null)
|
||||
{
|
||||
content.Handler = content.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (content.Handler?.PlatformView is SkiaView skiaContent)
|
||||
{
|
||||
handler.PlatformView.AddChild(skiaContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ public partial class LabelHandler : ViewHandler<ILabel, SkiaLabel>
|
|||
[nameof(ILabel.Padding)] = MapPadding,
|
||||
[nameof(ILabel.TextDecorations)] = MapTextDecorations,
|
||||
[nameof(ILabel.LineHeight)] = MapLineHeight,
|
||||
[nameof(ILabel.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -151,4 +153,22 @@ public partial class LabelHandler : ViewHandler<ILabel, SkiaLabel>
|
|||
handler.PlatformView.LineHeight = (float)label.LineHeight;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (label.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (label is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,12 @@ public partial class LabelHandler : ViewHandler<ILabel, SkiaLabel>
|
|||
[nameof(ITextAlignment.VerticalTextAlignment)] = MapVerticalTextAlignment,
|
||||
[nameof(ILabel.TextDecorations)] = MapTextDecorations,
|
||||
[nameof(ILabel.LineHeight)] = MapLineHeight,
|
||||
["LineBreakMode"] = MapLineBreakMode,
|
||||
["MaxLines"] = MapMaxLines,
|
||||
[nameof(IPadding.Padding)] = MapPadding,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(IView.VerticalLayoutAlignment)] = MapVerticalLayoutAlignment,
|
||||
[nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment,
|
||||
};
|
||||
|
||||
public static CommandMapper<ILabel, LabelHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
|
|
@ -121,6 +125,37 @@ public partial class LabelHandler : ViewHandler<ILabel, SkiaLabel>
|
|||
handler.PlatformView.LineHeight = (float)label.LineHeight;
|
||||
}
|
||||
|
||||
public static void MapLineBreakMode(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
// LineBreakMode is on Label control, not ILabel interface
|
||||
if (label is Microsoft.Maui.Controls.Label mauiLabel)
|
||||
{
|
||||
handler.PlatformView.LineBreakMode = mauiLabel.LineBreakMode switch
|
||||
{
|
||||
Microsoft.Maui.LineBreakMode.NoWrap => Platform.LineBreakMode.NoWrap,
|
||||
Microsoft.Maui.LineBreakMode.WordWrap => Platform.LineBreakMode.WordWrap,
|
||||
Microsoft.Maui.LineBreakMode.CharacterWrap => Platform.LineBreakMode.CharacterWrap,
|
||||
Microsoft.Maui.LineBreakMode.HeadTruncation => Platform.LineBreakMode.HeadTruncation,
|
||||
Microsoft.Maui.LineBreakMode.TailTruncation => Platform.LineBreakMode.TailTruncation,
|
||||
Microsoft.Maui.LineBreakMode.MiddleTruncation => Platform.LineBreakMode.MiddleTruncation,
|
||||
_ => Platform.LineBreakMode.TailTruncation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapMaxLines(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
// MaxLines is on Label control, not ILabel interface
|
||||
if (label is Microsoft.Maui.Controls.Label mauiLabel)
|
||||
{
|
||||
handler.PlatformView.MaxLines = mauiLabel.MaxLines;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapPadding(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
|
@ -142,4 +177,32 @@ public partial class LabelHandler : ViewHandler<ILabel, SkiaLabel>
|
|||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapVerticalLayoutAlignment(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
handler.PlatformView.VerticalOptions = label.VerticalLayoutAlignment switch
|
||||
{
|
||||
Primitives.LayoutAlignment.Start => LayoutOptions.Start,
|
||||
Primitives.LayoutAlignment.Center => LayoutOptions.Center,
|
||||
Primitives.LayoutAlignment.End => LayoutOptions.End,
|
||||
Primitives.LayoutAlignment.Fill => LayoutOptions.Fill,
|
||||
_ => LayoutOptions.Start
|
||||
};
|
||||
}
|
||||
|
||||
public static void MapHorizontalLayoutAlignment(LabelHandler handler, ILabel label)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
|
||||
handler.PlatformView.HorizontalOptions = label.HorizontalLayoutAlignment switch
|
||||
{
|
||||
Primitives.LayoutAlignment.Start => LayoutOptions.Start,
|
||||
Primitives.LayoutAlignment.Center => LayoutOptions.Center,
|
||||
Primitives.LayoutAlignment.End => LayoutOptions.End,
|
||||
Primitives.LayoutAlignment.Fill => LayoutOptions.Fill,
|
||||
_ => LayoutOptions.Start
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
public static IPropertyMapper<ILayout, LayoutHandler> Mapper = new PropertyMapper<ILayout, LayoutHandler>(ViewHandler.ViewMapper)
|
||||
{
|
||||
[nameof(ILayout.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
[nameof(ILayout.ClipsToBounds)] = MapClipsToBounds,
|
||||
[nameof(IPadding.Padding)] = MapPadding,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -53,8 +55,46 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
return new SkiaStackLayout();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaLayoutView platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
// Explicitly map BackgroundColor since it may be set before handler creation
|
||||
// (e.g., in ItemTemplates for CollectionView)
|
||||
if (VirtualView is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
platformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
platformView.Invalidate();
|
||||
}
|
||||
|
||||
// Add existing children (important for template-created views)
|
||||
if (VirtualView is ILayout layout && MauiContext != null)
|
||||
{
|
||||
for (int i = 0; i < layout.Count; i++)
|
||||
{
|
||||
var child = layout[i];
|
||||
if (child == null) continue;
|
||||
|
||||
// Create handler for child if it doesn't exist
|
||||
if (child.Handler == null)
|
||||
{
|
||||
child.Handler = child.ToHandler(MauiContext);
|
||||
}
|
||||
|
||||
if (child.Handler?.PlatformView is SkiaView skiaChild)
|
||||
{
|
||||
platformView.AddChild(skiaChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackground(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
// Don't override if BackgroundColor is explicitly set
|
||||
if (layout is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
return;
|
||||
|
||||
var background = layout.Background;
|
||||
if (background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
|
|
@ -63,12 +103,36 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
if (layout is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapClipsToBounds(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
handler.PlatformView.ClipToBounds = layout.ClipsToBounds;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapPadding(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
if (layout is IPadding paddable)
|
||||
{
|
||||
var padding = paddable.Padding;
|
||||
handler.PlatformView.Padding = new SKRect(
|
||||
(float)padding.Left,
|
||||
(float)padding.Top,
|
||||
(float)padding.Right,
|
||||
(float)padding.Bottom);
|
||||
handler.PlatformView.InvalidateMeasure();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapAdd(LayoutHandler handler, ILayout layout, object? arg)
|
||||
{
|
||||
if (arg is LayoutHandlerUpdate update)
|
||||
|
|
@ -194,9 +258,16 @@ public partial class GridHandler : LayoutHandler
|
|||
{
|
||||
[nameof(IGridLayout.ColumnSpacing)] = MapColumnSpacing,
|
||||
[nameof(IGridLayout.RowSpacing)] = MapRowSpacing,
|
||||
[nameof(IGridLayout.RowDefinitions)] = MapRowDefinitions,
|
||||
[nameof(IGridLayout.ColumnDefinitions)] = MapColumnDefinitions,
|
||||
};
|
||||
|
||||
public GridHandler() : base(Mapper)
|
||||
public static new CommandMapper<IGridLayout, GridHandler> GridCommandMapper = new(LayoutHandler.CommandMapper)
|
||||
{
|
||||
["Add"] = MapGridAdd,
|
||||
};
|
||||
|
||||
public GridHandler() : base(Mapper, GridCommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +276,52 @@ public partial class GridHandler : LayoutHandler
|
|||
return new SkiaGrid();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaLayoutView platformView)
|
||||
{
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Called! VirtualView={VirtualView?.GetType().Name}, PlatformView={platformView?.GetType().Name}, MauiContext={(MauiContext != null ? "set" : "null")}");
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
// Map definitions on connect
|
||||
if (VirtualView is IGridLayout gridLayout && platformView is SkiaGrid grid && MauiContext != null)
|
||||
{
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Grid has {gridLayout.Count} children, RowDefs={gridLayout.RowDefinitions?.Count ?? 0}");
|
||||
UpdateRowDefinitions(grid, gridLayout);
|
||||
UpdateColumnDefinitions(grid, gridLayout);
|
||||
|
||||
// Add existing children (important for template-created views)
|
||||
for (int i = 0; i < gridLayout.Count; i++)
|
||||
{
|
||||
var child = gridLayout[i];
|
||||
if (child == null) continue;
|
||||
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Child[{i}]: {child.GetType().Name}, Handler={child.Handler?.GetType().Name ?? "null"}");
|
||||
|
||||
// Create handler for child if it doesn't exist
|
||||
if (child.Handler == null)
|
||||
{
|
||||
child.Handler = child.ToHandler(MauiContext);
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Created handler for child[{i}]: {child.Handler?.GetType().Name ?? "failed"}");
|
||||
}
|
||||
|
||||
if (child.Handler?.PlatformView is SkiaView skiaChild)
|
||||
{
|
||||
// Get grid position from attached properties
|
||||
int row = 0, column = 0, rowSpan = 1, columnSpan = 1;
|
||||
if (child is Microsoft.Maui.Controls.View mauiView)
|
||||
{
|
||||
row = Microsoft.Maui.Controls.Grid.GetRow(mauiView);
|
||||
column = Microsoft.Maui.Controls.Grid.GetColumn(mauiView);
|
||||
rowSpan = Microsoft.Maui.Controls.Grid.GetRowSpan(mauiView);
|
||||
columnSpan = Microsoft.Maui.Controls.Grid.GetColumnSpan(mauiView);
|
||||
}
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Adding child[{i}] at row={row}, col={column}");
|
||||
grid.AddChild(skiaChild, row, column, rowSpan, columnSpan);
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[GridHandler.ConnectHandler] Grid now has {grid.Children.Count} SkiaView children");
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapColumnSpacing(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is SkiaGrid grid)
|
||||
|
|
@ -222,6 +339,79 @@ public partial class GridHandler : LayoutHandler
|
|||
grid.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapRowDefinitions(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is SkiaGrid grid)
|
||||
{
|
||||
UpdateRowDefinitions(grid, layout);
|
||||
grid.InvalidateMeasure();
|
||||
grid.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapColumnDefinitions(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is SkiaGrid grid)
|
||||
{
|
||||
UpdateColumnDefinitions(grid, layout);
|
||||
grid.InvalidateMeasure();
|
||||
grid.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateRowDefinitions(SkiaGrid grid, IGridLayout layout)
|
||||
{
|
||||
grid.RowDefinitions.Clear();
|
||||
foreach (var rowDef in layout.RowDefinitions)
|
||||
{
|
||||
var height = rowDef.Height;
|
||||
if (height.IsAbsolute)
|
||||
grid.RowDefinitions.Add(new GridLength((float)height.Value, GridUnitType.Absolute));
|
||||
else if (height.IsAuto)
|
||||
grid.RowDefinitions.Add(GridLength.Auto);
|
||||
else // Star
|
||||
grid.RowDefinitions.Add(new GridLength((float)height.Value, GridUnitType.Star));
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateColumnDefinitions(SkiaGrid grid, IGridLayout layout)
|
||||
{
|
||||
grid.ColumnDefinitions.Clear();
|
||||
foreach (var colDef in layout.ColumnDefinitions)
|
||||
{
|
||||
var width = colDef.Width;
|
||||
if (width.IsAbsolute)
|
||||
grid.ColumnDefinitions.Add(new GridLength((float)width.Value, GridUnitType.Absolute));
|
||||
else if (width.IsAuto)
|
||||
grid.ColumnDefinitions.Add(GridLength.Auto);
|
||||
else // Star
|
||||
grid.ColumnDefinitions.Add(new GridLength((float)width.Value, GridUnitType.Star));
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapGridAdd(GridHandler handler, ILayout layout, object? arg)
|
||||
{
|
||||
if (arg is LayoutHandlerUpdate update && handler.PlatformView is SkiaGrid grid)
|
||||
{
|
||||
var childHandler = update.View.Handler;
|
||||
if (childHandler?.PlatformView is SkiaView skiaView)
|
||||
{
|
||||
// Get grid position from attached properties
|
||||
int row = 0, column = 0, rowSpan = 1, columnSpan = 1;
|
||||
|
||||
if (update.View is Microsoft.Maui.Controls.View mauiView)
|
||||
{
|
||||
row = Microsoft.Maui.Controls.Grid.GetRow(mauiView);
|
||||
column = Microsoft.Maui.Controls.Grid.GetColumn(mauiView);
|
||||
rowSpan = Microsoft.Maui.Controls.Grid.GetRowSpan(mauiView);
|
||||
columnSpan = Microsoft.Maui.Controls.Grid.GetColumnSpan(mauiView);
|
||||
}
|
||||
|
||||
grid.AddChild(skiaView, row, column, rowSpan, columnSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
{
|
||||
[nameof(ILayout.ClipsToBounds)] = MapClipsToBounds,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(IPadding.Padding)] = MapPadding,
|
||||
};
|
||||
|
||||
public static CommandMapper<ILayout, LayoutHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
|
|
@ -42,6 +43,38 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
return new SkiaStackLayout();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaLayoutView platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
// Create handlers for all children and add them to the platform view
|
||||
if (VirtualView == null || MauiContext == null) return;
|
||||
|
||||
// Explicitly map BackgroundColor since it may be set before handler creation
|
||||
if (VirtualView is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
platformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
}
|
||||
|
||||
for (int i = 0; i < VirtualView.Count; i++)
|
||||
{
|
||||
var child = VirtualView[i];
|
||||
if (child == null) continue;
|
||||
|
||||
// Create handler for child if it doesn't exist
|
||||
if (child.Handler == null)
|
||||
{
|
||||
child.Handler = child.ToHandler(MauiContext);
|
||||
}
|
||||
|
||||
// Add child's platform view to our layout
|
||||
if (child.Handler?.PlatformView is SkiaView skiaChild)
|
||||
{
|
||||
platformView.AddChild(skiaChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapClipsToBounds(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
if (handler.PlatformView == null) return;
|
||||
|
|
@ -102,6 +135,23 @@ public partial class LayoutHandler : ViewHandler<ILayout, SkiaLayoutView>
|
|||
// Force re-layout
|
||||
handler.PlatformView?.InvalidateMeasure();
|
||||
}
|
||||
|
||||
public static void MapPadding(LayoutHandler handler, ILayout layout)
|
||||
{
|
||||
if (handler.PlatformView == null) return;
|
||||
|
||||
if (layout is IPadding paddable)
|
||||
{
|
||||
var padding = paddable.Padding;
|
||||
handler.PlatformView.Padding = new SKRect(
|
||||
(float)padding.Left,
|
||||
(float)padding.Top,
|
||||
(float)padding.Right,
|
||||
(float)padding.Bottom);
|
||||
handler.PlatformView.InvalidateMeasure();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -138,6 +188,29 @@ public partial class StackLayoutHandler : LayoutHandler
|
|||
return new SkiaStackLayout();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaLayoutView platformView)
|
||||
{
|
||||
// Set orientation first
|
||||
if (platformView is SkiaStackLayout stackLayout && VirtualView is IStackLayout stackView)
|
||||
{
|
||||
// Determine orientation based on view type
|
||||
if (VirtualView is Microsoft.Maui.Controls.HorizontalStackLayout)
|
||||
{
|
||||
stackLayout.Orientation = StackOrientation.Horizontal;
|
||||
}
|
||||
else if (VirtualView is Microsoft.Maui.Controls.VerticalStackLayout ||
|
||||
VirtualView is Microsoft.Maui.Controls.StackLayout)
|
||||
{
|
||||
stackLayout.Orientation = StackOrientation.Vertical;
|
||||
}
|
||||
|
||||
stackLayout.Spacing = (float)stackView.Spacing;
|
||||
}
|
||||
|
||||
// Let base handle children
|
||||
base.ConnectHandler(platformView);
|
||||
}
|
||||
|
||||
public static void MapSpacing(StackLayoutHandler handler, IStackLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is SkiaStackLayout stackLayout)
|
||||
|
|
@ -156,6 +229,8 @@ public partial class GridHandler : LayoutHandler
|
|||
{
|
||||
[nameof(IGridLayout.RowSpacing)] = MapRowSpacing,
|
||||
[nameof(IGridLayout.ColumnSpacing)] = MapColumnSpacing,
|
||||
[nameof(IGridLayout.RowDefinitions)] = MapRowDefinitions,
|
||||
[nameof(IGridLayout.ColumnDefinitions)] = MapColumnDefinitions,
|
||||
};
|
||||
|
||||
public GridHandler() : base(Mapper)
|
||||
|
|
@ -167,6 +242,80 @@ public partial class GridHandler : LayoutHandler
|
|||
return new SkiaGrid();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaLayoutView platformView)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Don't call base - we handle children specially for Grid
|
||||
if (VirtualView is not IGridLayout gridLayout || MauiContext == null || platformView is not SkiaGrid grid) return;
|
||||
|
||||
Console.WriteLine($"[GridHandler] ConnectHandler: {gridLayout.Count} children, {gridLayout.RowDefinitions.Count} rows, {gridLayout.ColumnDefinitions.Count} cols");
|
||||
|
||||
// Explicitly map BackgroundColor since it may be set before handler creation
|
||||
if (VirtualView is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
platformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
}
|
||||
|
||||
// Explicitly map Padding since it may be set before handler creation
|
||||
if (VirtualView is IPadding paddable)
|
||||
{
|
||||
var padding = paddable.Padding;
|
||||
platformView.Padding = new SKRect(
|
||||
(float)padding.Left,
|
||||
(float)padding.Top,
|
||||
(float)padding.Right,
|
||||
(float)padding.Bottom);
|
||||
Console.WriteLine($"[GridHandler] Applied Padding: L={padding.Left}, T={padding.Top}, R={padding.Right}, B={padding.Bottom}");
|
||||
}
|
||||
|
||||
// Map row/column definitions first
|
||||
MapRowDefinitions(this, gridLayout);
|
||||
MapColumnDefinitions(this, gridLayout);
|
||||
|
||||
// Add each child with its row/column position
|
||||
for (int i = 0; i < gridLayout.Count; i++)
|
||||
{
|
||||
var child = gridLayout[i];
|
||||
if (child == null) continue;
|
||||
|
||||
Console.WriteLine($"[GridHandler] Processing child {i}: {child.GetType().Name}");
|
||||
|
||||
// Create handler for child if it doesn't exist
|
||||
if (child.Handler == null)
|
||||
{
|
||||
child.Handler = child.ToHandler(MauiContext);
|
||||
}
|
||||
|
||||
// Get grid position from attached properties
|
||||
int row = 0, column = 0, rowSpan = 1, columnSpan = 1;
|
||||
if (child is Microsoft.Maui.Controls.View mauiView)
|
||||
{
|
||||
row = Microsoft.Maui.Controls.Grid.GetRow(mauiView);
|
||||
column = Microsoft.Maui.Controls.Grid.GetColumn(mauiView);
|
||||
rowSpan = Microsoft.Maui.Controls.Grid.GetRowSpan(mauiView);
|
||||
columnSpan = Microsoft.Maui.Controls.Grid.GetColumnSpan(mauiView);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[GridHandler] Child {i} at row={row}, col={column}, handler={child.Handler?.GetType().Name}");
|
||||
|
||||
// Add child's platform view to our grid
|
||||
if (child.Handler?.PlatformView is SkiaView skiaChild)
|
||||
{
|
||||
grid.AddChild(skiaChild, row, column, rowSpan, columnSpan);
|
||||
Console.WriteLine($"[GridHandler] Added child {i} to grid");
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[GridHandler] ConnectHandler complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[GridHandler] EXCEPTION in ConnectHandler: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.WriteLine($"[GridHandler] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapRowSpacing(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is SkiaGrid grid)
|
||||
|
|
@ -182,4 +331,38 @@ public partial class GridHandler : LayoutHandler
|
|||
grid.ColumnSpacing = (float)layout.ColumnSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapRowDefinitions(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is not SkiaGrid grid) return;
|
||||
|
||||
grid.RowDefinitions.Clear();
|
||||
foreach (var rowDef in layout.RowDefinitions)
|
||||
{
|
||||
var height = rowDef.Height;
|
||||
if (height.IsAbsolute)
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength((float)height.Value, Microsoft.Maui.Platform.GridUnitType.Absolute));
|
||||
else if (height.IsAuto)
|
||||
grid.RowDefinitions.Add(Microsoft.Maui.Platform.GridLength.Auto);
|
||||
else // Star
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength((float)height.Value, Microsoft.Maui.Platform.GridUnitType.Star));
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapColumnDefinitions(GridHandler handler, IGridLayout layout)
|
||||
{
|
||||
if (handler.PlatformView is not SkiaGrid grid) return;
|
||||
|
||||
grid.ColumnDefinitions.Clear();
|
||||
foreach (var colDef in layout.ColumnDefinitions)
|
||||
{
|
||||
var width = colDef.Width;
|
||||
if (width.IsAbsolute)
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength((float)width.Value, Microsoft.Maui.Platform.GridUnitType.Absolute));
|
||||
else if (width.IsAuto)
|
||||
grid.ColumnDefinitions.Add(Microsoft.Maui.Platform.GridLength.Auto);
|
||||
else // Star
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength((float)width.Value, Microsoft.Maui.Platform.GridUnitType.Star));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.Maui.Graphics;
|
|||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
|
|
@ -50,10 +51,15 @@ public partial class NavigationPageHandler : ViewHandler<NavigationPage, SkiaNav
|
|||
platformView.Popped += OnPopped;
|
||||
platformView.PoppedToRoot += OnPoppedToRoot;
|
||||
|
||||
// Set initial root page if exists
|
||||
if (VirtualView.CurrentPage != null)
|
||||
// Subscribe to navigation events from virtual view
|
||||
if (VirtualView != null)
|
||||
{
|
||||
SetupInitialPage();
|
||||
VirtualView.Pushed += OnVirtualViewPushed;
|
||||
VirtualView.Popped += OnVirtualViewPopped;
|
||||
VirtualView.PoppedToRoot += OnVirtualViewPoppedToRoot;
|
||||
|
||||
// Set up initial navigation stack
|
||||
SetupNavigationStack();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,16 +68,195 @@ public partial class NavigationPageHandler : ViewHandler<NavigationPage, SkiaNav
|
|||
platformView.Pushed -= OnPushed;
|
||||
platformView.Popped -= OnPopped;
|
||||
platformView.PoppedToRoot -= OnPoppedToRoot;
|
||||
|
||||
if (VirtualView != null)
|
||||
{
|
||||
VirtualView.Pushed -= OnVirtualViewPushed;
|
||||
VirtualView.Popped -= OnVirtualViewPopped;
|
||||
VirtualView.PoppedToRoot -= OnVirtualViewPoppedToRoot;
|
||||
}
|
||||
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
private void SetupInitialPage()
|
||||
private void SetupNavigationStack()
|
||||
{
|
||||
var currentPage = VirtualView.CurrentPage;
|
||||
if (currentPage?.Handler?.PlatformView is SkiaPage skiaPage)
|
||||
if (VirtualView == null || PlatformView == null || MauiContext == null) return;
|
||||
|
||||
// Get all pages in the navigation stack
|
||||
var pages = VirtualView.Navigation.NavigationStack.ToList();
|
||||
Console.WriteLine($"[NavigationPageHandler] Setting up {pages.Count} pages");
|
||||
|
||||
// If no pages in stack, check CurrentPage
|
||||
if (pages.Count == 0 && VirtualView.CurrentPage != null)
|
||||
{
|
||||
PlatformView.SetRootPage(skiaPage);
|
||||
Console.WriteLine($"[NavigationPageHandler] No pages in stack, using CurrentPage: {VirtualView.CurrentPage.Title}");
|
||||
pages.Add(VirtualView.CurrentPage);
|
||||
}
|
||||
|
||||
foreach (var page in pages)
|
||||
{
|
||||
// Ensure the page has a handler
|
||||
if (page.Handler == null)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Creating handler for: {page.Title}");
|
||||
page.Handler = page.ToHandler(MauiContext);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[NavigationPageHandler] Page handler type: {page.Handler?.GetType().Name}");
|
||||
Console.WriteLine($"[NavigationPageHandler] Page PlatformView type: {page.Handler?.PlatformView?.GetType().Name}");
|
||||
|
||||
if (page.Handler?.PlatformView is SkiaPage skiaPage)
|
||||
{
|
||||
// Set navigation bar properties
|
||||
skiaPage.ShowNavigationBar = true;
|
||||
skiaPage.TitleBarColor = PlatformView.BarBackgroundColor;
|
||||
skiaPage.TitleTextColor = PlatformView.BarTextColor;
|
||||
skiaPage.Title = page.Title ?? "";
|
||||
|
||||
Console.WriteLine($"[NavigationPageHandler] SkiaPage content: {skiaPage.Content?.GetType().Name ?? "null"}");
|
||||
|
||||
// If content is null, try to get it from ContentPage
|
||||
if (skiaPage.Content == null && page is ContentPage contentPage && contentPage.Content != null)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Content is null, manually creating handler for: {contentPage.Content.GetType().Name}");
|
||||
if (contentPage.Content.Handler == null)
|
||||
{
|
||||
contentPage.Content.Handler = contentPage.Content.ToHandler(MauiContext);
|
||||
}
|
||||
if (contentPage.Content.Handler?.PlatformView is SkiaView skiaContent)
|
||||
{
|
||||
skiaPage.Content = skiaContent;
|
||||
Console.WriteLine($"[NavigationPageHandler] Set content to: {skiaContent.GetType().Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Map toolbar items
|
||||
MapToolbarItems(skiaPage, page);
|
||||
|
||||
if (PlatformView.StackDepth == 0)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Setting root page: {page.Title}");
|
||||
PlatformView.SetRootPage(skiaPage);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Pushing page: {page.Title}");
|
||||
PlatformView.Push(skiaPage, false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Failed to get SkiaPage for: {page.Title}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Dictionary<Page, (SkiaPage, INotifyCollectionChanged)> _toolbarSubscriptions = new();
|
||||
|
||||
private void MapToolbarItems(SkiaPage skiaPage, Page page)
|
||||
{
|
||||
if (skiaPage is SkiaContentPage contentPage)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] MapToolbarItems for '{page.Title}', count={page.ToolbarItems.Count}");
|
||||
|
||||
contentPage.ToolbarItems.Clear();
|
||||
foreach (var item in page.ToolbarItems)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Adding toolbar item: '{item.Text}', Order={item.Order}");
|
||||
// Default and Primary should both be treated as Primary (shown in toolbar)
|
||||
// Only Secondary goes to overflow menu
|
||||
var order = item.Order == ToolbarItemOrder.Secondary
|
||||
? SkiaToolbarItemOrder.Secondary
|
||||
: SkiaToolbarItemOrder.Primary;
|
||||
|
||||
// Create a command that invokes the Clicked event
|
||||
var toolbarItem = item; // Capture for closure
|
||||
var clickCommand = new RelayCommand(() =>
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] ToolbarItem '{toolbarItem.Text}' clicked, invoking...");
|
||||
// Use IMenuItemController to send the click
|
||||
if (toolbarItem is IMenuItemController menuController)
|
||||
{
|
||||
menuController.Activate();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: invoke Command if set
|
||||
toolbarItem.Command?.Execute(toolbarItem.CommandParameter);
|
||||
}
|
||||
});
|
||||
|
||||
contentPage.ToolbarItems.Add(new SkiaToolbarItem
|
||||
{
|
||||
Text = item.Text ?? "",
|
||||
Order = order,
|
||||
Command = clickCommand
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to ToolbarItems changes if not already subscribed
|
||||
if (page.ToolbarItems is INotifyCollectionChanged notifyCollection && !_toolbarSubscriptions.ContainsKey(page))
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Subscribing to ToolbarItems changes for '{page.Title}'");
|
||||
notifyCollection.CollectionChanged += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] ToolbarItems changed for '{page.Title}', action={e.Action}");
|
||||
MapToolbarItems(skiaPage, page);
|
||||
skiaPage.Invalidate();
|
||||
};
|
||||
_toolbarSubscriptions[page] = (skiaPage, notifyCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVirtualViewPushed(object? sender, Microsoft.Maui.Controls.NavigationEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] VirtualView Pushed: {e.Page?.Title}");
|
||||
if (e.Page == null || PlatformView == null || MauiContext == null) return;
|
||||
|
||||
// Ensure the page has a handler
|
||||
if (e.Page.Handler == null)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Creating handler for page: {e.Page.GetType().Name}");
|
||||
e.Page.Handler = e.Page.ToHandler(MauiContext);
|
||||
Console.WriteLine($"[NavigationPageHandler] Handler created: {e.Page.Handler?.GetType().Name}");
|
||||
}
|
||||
|
||||
if (e.Page.Handler?.PlatformView is SkiaPage skiaPage)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] Setting up skiaPage, content: {skiaPage.Content?.GetType().Name ?? "null"}");
|
||||
skiaPage.ShowNavigationBar = true;
|
||||
skiaPage.TitleBarColor = PlatformView.BarBackgroundColor;
|
||||
skiaPage.TitleTextColor = PlatformView.BarTextColor;
|
||||
Console.WriteLine($"[NavigationPageHandler] Mapping toolbar items");
|
||||
MapToolbarItems(skiaPage, e.Page);
|
||||
Console.WriteLine($"[NavigationPageHandler] Pushing page to platform");
|
||||
PlatformView.Push(skiaPage, true);
|
||||
Console.WriteLine($"[NavigationPageHandler] Push complete");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] EXCEPTION in OnVirtualViewPushed: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.WriteLine($"[NavigationPageHandler] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVirtualViewPopped(object? sender, Microsoft.Maui.Controls.NavigationEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] VirtualView Popped: {e.Page?.Title}");
|
||||
// Pop on the platform side to sync with MAUI navigation
|
||||
PlatformView?.Pop(true);
|
||||
}
|
||||
|
||||
private void OnVirtualViewPoppedToRoot(object? sender, Microsoft.Maui.Controls.NavigationEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[NavigationPageHandler] VirtualView PoppedToRoot");
|
||||
PlatformView?.PopToRoot(true);
|
||||
}
|
||||
|
||||
private void OnPushed(object? sender, NavigationEventArgs e)
|
||||
|
|
@ -81,7 +266,12 @@ public partial class NavigationPageHandler : ViewHandler<NavigationPage, SkiaNav
|
|||
|
||||
private void OnPopped(object? sender, NavigationEventArgs e)
|
||||
{
|
||||
// Sync back to virtual view if needed
|
||||
// Sync back to virtual view - pop from MAUI navigation stack
|
||||
if (VirtualView?.Navigation.NavigationStack.Count > 1)
|
||||
{
|
||||
// Don't trigger another pop on platform side
|
||||
VirtualView.Navigation.RemovePage(VirtualView.Navigation.NavigationStack.Last());
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPoppedToRoot(object? sender, NavigationEventArgs e)
|
||||
|
|
@ -131,14 +321,29 @@ public partial class NavigationPageHandler : ViewHandler<NavigationPage, SkiaNav
|
|||
|
||||
public static void MapRequestNavigation(NavigationPageHandler handler, NavigationPage navigationPage, object? args)
|
||||
{
|
||||
if (handler.PlatformView is null || args is not NavigationRequest request)
|
||||
if (handler.PlatformView is null || handler.MauiContext is null || args is not NavigationRequest request)
|
||||
return;
|
||||
|
||||
Console.WriteLine($"[NavigationPageHandler] MapRequestNavigation: {request.NavigationStack.Count} pages");
|
||||
|
||||
// Handle navigation request
|
||||
foreach (var page in request.NavigationStack)
|
||||
foreach (var view in request.NavigationStack)
|
||||
{
|
||||
if (view is not Page page) continue;
|
||||
|
||||
// Ensure handler exists
|
||||
if (page.Handler == null)
|
||||
{
|
||||
page.Handler = page.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (page.Handler?.PlatformView is SkiaPage skiaPage)
|
||||
{
|
||||
skiaPage.ShowNavigationBar = true;
|
||||
skiaPage.TitleBarColor = handler.PlatformView.BarBackgroundColor;
|
||||
skiaPage.TitleTextColor = handler.PlatformView.BarTextColor;
|
||||
handler.MapToolbarItems(skiaPage, page);
|
||||
|
||||
if (handler.PlatformView.StackDepth == 0)
|
||||
{
|
||||
handler.PlatformView.SetRootPage(skiaPage);
|
||||
|
|
@ -151,3 +356,26 @@ public partial class NavigationPageHandler : ViewHandler<NavigationPage, SkiaNav
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple relay command for invoking actions.
|
||||
/// </summary>
|
||||
internal class RelayCommand : System.Windows.Input.ICommand
|
||||
{
|
||||
private readonly Action _execute;
|
||||
private readonly Func<bool>? _canExecute;
|
||||
|
||||
public RelayCommand(Action execute, Func<bool>? canExecute = null)
|
||||
{
|
||||
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged;
|
||||
|
||||
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
|
||||
|
||||
public void Execute(object? parameter) => _execute();
|
||||
|
||||
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ public partial class PageHandler : ViewHandler<Page, SkiaPage>
|
|||
|
||||
private void OnAppearing(object? sender, EventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[PageHandler] OnAppearing received for: {VirtualView?.Title}");
|
||||
(VirtualView as IPageController)?.SendAppearing();
|
||||
}
|
||||
|
||||
|
|
@ -133,18 +134,29 @@ public partial class ContentPageHandler : PageHandler
|
|||
|
||||
public static void MapContent(ContentPageHandler handler, ContentPage page)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
if (handler.PlatformView is null || handler.MauiContext is null) return;
|
||||
|
||||
// Get the platform view for the content
|
||||
var content = page.Content;
|
||||
if (content != null)
|
||||
{
|
||||
// The content's handler should provide the platform view
|
||||
var contentHandler = content.Handler;
|
||||
if (contentHandler?.PlatformView is SkiaView skiaContent)
|
||||
// Create handler for content if it doesn't exist
|
||||
if (content.Handler == null)
|
||||
{
|
||||
Console.WriteLine($"[ContentPageHandler] Creating handler for content: {content.GetType().Name}");
|
||||
content.Handler = content.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
// The content's handler should provide the platform view
|
||||
if (content.Handler?.PlatformView is SkiaView skiaContent)
|
||||
{
|
||||
Console.WriteLine($"[ContentPageHandler] Setting content: {skiaContent.GetType().Name}");
|
||||
handler.PlatformView.Content = skiaContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[ContentPageHandler] Content handler PlatformView is not SkiaView: {content.Handler?.PlatformView?.GetType().Name ?? "null"}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Microsoft.Maui.Graphics;
|
|||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ public partial class PickerHandler : ViewHandler<IPicker, SkiaPicker>
|
|||
[nameof(IPicker.HorizontalTextAlignment)] = MapHorizontalTextAlignment,
|
||||
[nameof(IPicker.VerticalTextAlignment)] = MapVerticalTextAlignment,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(Picker.ItemsSource)] = MapItemsSource,
|
||||
};
|
||||
|
||||
public static CommandMapper<IPicker, PickerHandler> CommandMapper =
|
||||
|
|
@ -32,6 +34,8 @@ public partial class PickerHandler : ViewHandler<IPicker, SkiaPicker>
|
|||
{
|
||||
};
|
||||
|
||||
private INotifyCollectionChanged? _itemsCollection;
|
||||
|
||||
public PickerHandler() : base(Mapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
|
@ -51,6 +55,13 @@ public partial class PickerHandler : ViewHandler<IPicker, SkiaPicker>
|
|||
base.ConnectHandler(platformView);
|
||||
platformView.SelectedIndexChanged += OnSelectedIndexChanged;
|
||||
|
||||
// Subscribe to items collection changes
|
||||
if (VirtualView is Picker picker && picker.Items is INotifyCollectionChanged items)
|
||||
{
|
||||
_itemsCollection = items;
|
||||
_itemsCollection.CollectionChanged += OnItemsCollectionChanged;
|
||||
}
|
||||
|
||||
// Load items
|
||||
ReloadItems();
|
||||
}
|
||||
|
|
@ -58,9 +69,21 @@ public partial class PickerHandler : ViewHandler<IPicker, SkiaPicker>
|
|||
protected override void DisconnectHandler(SkiaPicker platformView)
|
||||
{
|
||||
platformView.SelectedIndexChanged -= OnSelectedIndexChanged;
|
||||
|
||||
if (_itemsCollection != null)
|
||||
{
|
||||
_itemsCollection.CollectionChanged -= OnItemsCollectionChanged;
|
||||
_itemsCollection = null;
|
||||
}
|
||||
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ReloadItems();
|
||||
}
|
||||
|
||||
private void OnSelectedIndexChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (VirtualView is null || PlatformView is null) return;
|
||||
|
|
@ -130,4 +153,9 @@ public partial class PickerHandler : ViewHandler<IPicker, SkiaPicker>
|
|||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapItemsSource(PickerHandler handler, IPicker picker)
|
||||
{
|
||||
handler.ReloadItems();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public partial class ProgressBarHandler : ViewHandler<IProgress, SkiaProgressBar
|
|||
[nameof(IProgress.Progress)] = MapProgress,
|
||||
[nameof(IProgress.ProgressColor)] = MapProgressColor,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<IProgress, ProgressBarHandler> CommandMapper = new(ViewHandler.ViewCommandMapper);
|
||||
|
|
@ -40,4 +42,22 @@ public partial class ProgressBarHandler : ViewHandler<IProgress, SkiaProgressBar
|
|||
handler.PlatformView.IsEnabled = progress.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(ProgressBarHandler handler, IProgress progress)
|
||||
{
|
||||
if (progress.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(ProgressBarHandler handler, IProgress progress)
|
||||
{
|
||||
if (progress is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Handlers;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ScrollView on Linux using SkiaScrollView.
|
||||
/// </summary>
|
||||
public partial class ScrollViewHandler : ViewHandler<IScrollView, SkiaScrollView>
|
||||
{
|
||||
public static IPropertyMapper<IScrollView, ScrollViewHandler> Mapper =
|
||||
new PropertyMapper<IScrollView, ScrollViewHandler>(ViewMapper)
|
||||
{
|
||||
[nameof(IScrollView.Content)] = MapContent,
|
||||
[nameof(IScrollView.HorizontalScrollBarVisibility)] = MapHorizontalScrollBarVisibility,
|
||||
[nameof(IScrollView.VerticalScrollBarVisibility)] = MapVerticalScrollBarVisibility,
|
||||
[nameof(IScrollView.Orientation)] = MapOrientation,
|
||||
};
|
||||
|
||||
public static CommandMapper<IScrollView, ScrollViewHandler> CommandMapper =
|
||||
new(ViewCommandMapper)
|
||||
{
|
||||
[nameof(IScrollView.RequestScrollTo)] = MapRequestScrollTo
|
||||
};
|
||||
|
||||
public ScrollViewHandler() : base(Mapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
public ScrollViewHandler(IPropertyMapper? mapper)
|
||||
: base(mapper ?? Mapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override SkiaScrollView CreatePlatformView()
|
||||
{
|
||||
return new SkiaScrollView();
|
||||
}
|
||||
|
||||
public static void MapContent(ScrollViewHandler handler, IScrollView scrollView)
|
||||
{
|
||||
if (handler.PlatformView == null || handler.MauiContext == null)
|
||||
return;
|
||||
|
||||
var content = scrollView.PresentedContent;
|
||||
if (content != null)
|
||||
{
|
||||
Console.WriteLine($"[ScrollViewHandler] MapContent: {content.GetType().Name}");
|
||||
|
||||
// Create handler for content if it doesn't exist
|
||||
if (content.Handler == null)
|
||||
{
|
||||
content.Handler = content.ToHandler(handler.MauiContext);
|
||||
}
|
||||
|
||||
if (content.Handler?.PlatformView is SkiaView skiaContent)
|
||||
{
|
||||
Console.WriteLine($"[ScrollViewHandler] Setting content: {skiaContent.GetType().Name}");
|
||||
handler.PlatformView.Content = skiaContent;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handler.PlatformView.Content = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapHorizontalScrollBarVisibility(ScrollViewHandler handler, IScrollView scrollView)
|
||||
{
|
||||
handler.PlatformView.HorizontalScrollBarVisibility = scrollView.HorizontalScrollBarVisibility switch
|
||||
{
|
||||
Microsoft.Maui.ScrollBarVisibility.Always => ScrollBarVisibility.Always,
|
||||
Microsoft.Maui.ScrollBarVisibility.Never => ScrollBarVisibility.Never,
|
||||
_ => ScrollBarVisibility.Default
|
||||
};
|
||||
}
|
||||
|
||||
public static void MapVerticalScrollBarVisibility(ScrollViewHandler handler, IScrollView scrollView)
|
||||
{
|
||||
handler.PlatformView.VerticalScrollBarVisibility = scrollView.VerticalScrollBarVisibility switch
|
||||
{
|
||||
Microsoft.Maui.ScrollBarVisibility.Always => ScrollBarVisibility.Always,
|
||||
Microsoft.Maui.ScrollBarVisibility.Never => ScrollBarVisibility.Never,
|
||||
_ => ScrollBarVisibility.Default
|
||||
};
|
||||
}
|
||||
|
||||
public static void MapOrientation(ScrollViewHandler handler, IScrollView scrollView)
|
||||
{
|
||||
handler.PlatformView.Orientation = scrollView.Orientation switch
|
||||
{
|
||||
Microsoft.Maui.ScrollOrientation.Horizontal => ScrollOrientation.Horizontal,
|
||||
Microsoft.Maui.ScrollOrientation.Both => ScrollOrientation.Both,
|
||||
Microsoft.Maui.ScrollOrientation.Neither => ScrollOrientation.Neither,
|
||||
_ => ScrollOrientation.Vertical
|
||||
};
|
||||
}
|
||||
|
||||
public static void MapRequestScrollTo(ScrollViewHandler handler, IScrollView scrollView, object? args)
|
||||
{
|
||||
if (args is ScrollToRequest request)
|
||||
{
|
||||
// Instant means no animation, so we pass !Instant for animated parameter
|
||||
handler.PlatformView.ScrollTo((float)request.HorizontalOffset, (float)request.VerticalOffset, !request.Instant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Graphics;
|
||||
using SkiaSharp;
|
||||
|
|
@ -10,13 +11,13 @@ namespace Microsoft.Maui.Platform.Linux.Handlers;
|
|||
/// <summary>
|
||||
/// Handler for Shell on Linux using Skia rendering.
|
||||
/// </summary>
|
||||
public partial class ShellHandler : ViewHandler<IView, SkiaShell>
|
||||
public partial class ShellHandler : ViewHandler<Shell, SkiaShell>
|
||||
{
|
||||
public static IPropertyMapper<IView, ShellHandler> Mapper = new PropertyMapper<IView, ShellHandler>(ViewHandler.ViewMapper)
|
||||
public static IPropertyMapper<Shell, ShellHandler> Mapper = new PropertyMapper<Shell, ShellHandler>(ViewHandler.ViewMapper)
|
||||
{
|
||||
};
|
||||
|
||||
public static CommandMapper<IView, ShellHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
public static CommandMapper<Shell, ShellHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
{
|
||||
};
|
||||
|
||||
|
|
@ -39,12 +40,26 @@ public partial class ShellHandler : ViewHandler<IView, SkiaShell>
|
|||
base.ConnectHandler(platformView);
|
||||
platformView.FlyoutIsPresentedChanged += OnFlyoutIsPresentedChanged;
|
||||
platformView.Navigated += OnNavigated;
|
||||
|
||||
// Subscribe to Shell navigation events
|
||||
if (VirtualView != null)
|
||||
{
|
||||
VirtualView.Navigating += OnShellNavigating;
|
||||
VirtualView.Navigated += OnShellNavigated;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaShell platformView)
|
||||
{
|
||||
platformView.FlyoutIsPresentedChanged -= OnFlyoutIsPresentedChanged;
|
||||
platformView.Navigated -= OnNavigated;
|
||||
|
||||
if (VirtualView != null)
|
||||
{
|
||||
VirtualView.Navigating -= OnShellNavigating;
|
||||
VirtualView.Navigated -= OnShellNavigated;
|
||||
}
|
||||
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +70,24 @@ public partial class ShellHandler : ViewHandler<IView, SkiaShell>
|
|||
|
||||
private void OnNavigated(object? sender, ShellNavigationEventArgs e)
|
||||
{
|
||||
// Handle navigation events
|
||||
// Handle platform navigation events
|
||||
}
|
||||
|
||||
private void OnShellNavigating(object? sender, ShellNavigatingEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[ShellHandler] Shell Navigating to: {e.Target?.Location}");
|
||||
|
||||
// Route to platform view
|
||||
if (PlatformView != null && e.Target?.Location != null)
|
||||
{
|
||||
var route = e.Target.Location.ToString().TrimStart('/');
|
||||
Console.WriteLine($"[ShellHandler] Routing to: {route}");
|
||||
PlatformView.GoToAsync(route);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnShellNavigated(object? sender, ShellNavigatedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[ShellHandler] Shell Navigated to: {e.Current?.Location}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public partial class SliderHandler : ViewHandler<ISlider, SkiaSlider>
|
|||
[nameof(ISlider.MaximumTrackColor)] = MapMaximumTrackColor,
|
||||
[nameof(ISlider.ThumbColor)] = MapThumbColor,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<ISlider, SliderHandler> CommandMapper = new(ViewHandler.ViewCommandMapper);
|
||||
|
|
@ -100,4 +102,22 @@ public partial class SliderHandler : ViewHandler<ISlider, SkiaSlider>
|
|||
handler.PlatformView.IsEnabled = slider.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(SliderHandler handler, ISlider slider)
|
||||
{
|
||||
if (slider.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(SliderHandler handler, ISlider slider)
|
||||
{
|
||||
if (slider is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public partial class SliderHandler : ViewHandler<ISlider, SkiaSlider>
|
|||
[nameof(ISlider.MaximumTrackColor)] = MapMaximumTrackColor,
|
||||
[nameof(ISlider.ThumbColor)] = MapThumbColor,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
};
|
||||
|
||||
public static CommandMapper<ISlider, SliderHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
|
|
@ -48,6 +49,15 @@ public partial class SliderHandler : ViewHandler<ISlider, SkiaSlider>
|
|||
platformView.ValueChanged += OnValueChanged;
|
||||
platformView.DragStarted += OnDragStarted;
|
||||
platformView.DragCompleted += OnDragCompleted;
|
||||
|
||||
// Sync properties that may have been set before handler connection
|
||||
if (VirtualView != null)
|
||||
{
|
||||
MapMinimum(this, VirtualView);
|
||||
MapMaximum(this, VirtualView);
|
||||
MapValue(this, VirtualView);
|
||||
MapIsEnabled(this, VirtualView);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaSlider platformView)
|
||||
|
|
@ -133,4 +143,11 @@ public partial class SliderHandler : ViewHandler<ISlider, SkiaSlider>
|
|||
handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapIsEnabled(SliderHandler handler, ISlider slider)
|
||||
{
|
||||
if (handler.PlatformView is null) return;
|
||||
handler.PlatformView.IsEnabled = slider.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ public partial class SwitchHandler : ViewHandler<ISwitch, SkiaSwitch>
|
|||
[nameof(ISwitch.TrackColor)] = MapTrackColor,
|
||||
[nameof(ISwitch.ThumbColor)] = MapThumbColor,
|
||||
[nameof(IView.IsEnabled)] = MapIsEnabled,
|
||||
[nameof(IView.Background)] = MapBackground,
|
||||
["BackgroundColor"] = MapBackgroundColor,
|
||||
};
|
||||
|
||||
public static CommandMapper<ISwitch, SwitchHandler> CommandMapper = new(ViewHandler.ViewCommandMapper);
|
||||
|
|
@ -71,4 +73,22 @@ public partial class SwitchHandler : ViewHandler<ISwitch, SkiaSwitch>
|
|||
handler.PlatformView.IsEnabled = @switch.IsEnabled;
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
|
||||
public static void MapBackground(SwitchHandler handler, ISwitch @switch)
|
||||
{
|
||||
if (@switch.Background is SolidColorBrush solidBrush && solidBrush.Color != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = solidBrush.Color.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapBackgroundColor(SwitchHandler handler, ISwitch @switch)
|
||||
{
|
||||
if (@switch is Microsoft.Maui.Controls.VisualElement ve && ve.BackgroundColor != null)
|
||||
{
|
||||
handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor();
|
||||
handler.PlatformView.Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Handlers;
|
||||
using Microsoft.Maui.Platform;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for WebView control on Linux using WebKitGTK.
|
||||
/// </summary>
|
||||
public partial class WebViewHandler : ViewHandler<IWebView, SkiaWebView>
|
||||
{
|
||||
public static IPropertyMapper<IWebView, WebViewHandler> Mapper = new PropertyMapper<IWebView, WebViewHandler>(ViewHandler.ViewMapper)
|
||||
{
|
||||
[nameof(IWebView.Source)] = MapSource,
|
||||
};
|
||||
|
||||
public static CommandMapper<IWebView, WebViewHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
||||
{
|
||||
[nameof(IWebView.GoBack)] = MapGoBack,
|
||||
[nameof(IWebView.GoForward)] = MapGoForward,
|
||||
[nameof(IWebView.Reload)] = MapReload,
|
||||
};
|
||||
|
||||
public WebViewHandler() : base(Mapper, CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
public WebViewHandler(IPropertyMapper? mapper = null, CommandMapper? commandMapper = null)
|
||||
: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override SkiaWebView CreatePlatformView()
|
||||
{
|
||||
return new SkiaWebView();
|
||||
}
|
||||
|
||||
protected override void ConnectHandler(SkiaWebView platformView)
|
||||
{
|
||||
base.ConnectHandler(platformView);
|
||||
|
||||
platformView.Navigating += OnNavigating;
|
||||
platformView.Navigated += OnNavigated;
|
||||
}
|
||||
|
||||
protected override void DisconnectHandler(SkiaWebView platformView)
|
||||
{
|
||||
platformView.Navigating -= OnNavigating;
|
||||
platformView.Navigated -= OnNavigated;
|
||||
|
||||
base.DisconnectHandler(platformView);
|
||||
}
|
||||
|
||||
private void OnNavigating(object? sender, WebNavigatingEventArgs e)
|
||||
{
|
||||
// Forward to virtual view if needed
|
||||
}
|
||||
|
||||
private void OnNavigated(object? sender, WebNavigatedEventArgs e)
|
||||
{
|
||||
// Forward to virtual view if needed
|
||||
}
|
||||
|
||||
public static void MapSource(WebViewHandler handler, IWebView webView)
|
||||
{
|
||||
if (handler.PlatformView == null) return;
|
||||
|
||||
var source = webView.Source;
|
||||
if (source is UrlWebViewSource urlSource)
|
||||
{
|
||||
handler.PlatformView.Source = urlSource.Url ?? "";
|
||||
}
|
||||
else if (source is HtmlWebViewSource htmlSource)
|
||||
{
|
||||
handler.PlatformView.Html = htmlSource.Html ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public static void MapGoBack(WebViewHandler handler, IWebView webView, object? args)
|
||||
{
|
||||
handler.PlatformView?.GoBack();
|
||||
}
|
||||
|
||||
public static void MapGoForward(WebViewHandler handler, IWebView webView, object? args)
|
||||
{
|
||||
handler.PlatformView?.GoForward();
|
||||
}
|
||||
|
||||
public static void MapReload(WebViewHandler handler, IWebView webView, object? args)
|
||||
{
|
||||
handler.PlatformView?.Reload();
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,7 @@ public partial class WindowHandler : ElementHandler<IWindow, SkiaWindow>
|
|||
|
||||
/// <summary>
|
||||
/// Skia window wrapper for Linux display servers.
|
||||
/// Handles rendering of content and popup overlays automatically.
|
||||
/// </summary>
|
||||
public class SkiaWindow
|
||||
{
|
||||
|
|
@ -164,6 +165,28 @@ public class SkiaWindow
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the window content and popup overlays to the canvas.
|
||||
/// This should be called by the platform rendering loop.
|
||||
/// </summary>
|
||||
public void Render(SKCanvas canvas)
|
||||
{
|
||||
// Clear background
|
||||
canvas.Clear(SKColors.White);
|
||||
|
||||
// Draw main content
|
||||
if (_content != null)
|
||||
{
|
||||
_content.Measure(new SKSize(_width, _height));
|
||||
_content.Arrange(new SKRect(0, 0, _width, _height));
|
||||
_content.Draw(canvas);
|
||||
}
|
||||
|
||||
// Draw popup overlays on top (dropdowns, date pickers, etc.)
|
||||
// This ensures popups always render above all other content
|
||||
SkiaView.DrawPopupOverlays(canvas);
|
||||
}
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
|
|
@ -8,9 +9,11 @@ using Microsoft.Maui.ApplicationModel.Communication;
|
|||
using Microsoft.Maui.ApplicationModel.DataTransfer;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using Microsoft.Maui.Platform.Linux.Services;
|
||||
using Microsoft.Maui.Platform.Linux.Converters;
|
||||
using Microsoft.Maui.Storage;
|
||||
using Microsoft.Maui.Platform.Linux.Handlers;
|
||||
using Microsoft.Maui.Controls;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Hosting;
|
||||
|
||||
|
|
@ -47,51 +50,69 @@ public static class LinuxMauiAppBuilderExtensions
|
|||
builder.Services.TryAddSingleton<IBrowser, BrowserService>();
|
||||
builder.Services.TryAddSingleton<IEmail, EmailService>();
|
||||
|
||||
// Register type converters for XAML support
|
||||
RegisterTypeConverters();
|
||||
|
||||
// Register Linux-specific handlers
|
||||
builder.ConfigureMauiHandlers(handlers =>
|
||||
{
|
||||
// Phase 1 - MVP controls
|
||||
handlers.AddHandler<IButton, ButtonHandler>();
|
||||
handlers.AddHandler<ILabel, LabelHandler>();
|
||||
handlers.AddHandler<IEntry, EntryHandler>();
|
||||
handlers.AddHandler<ICheckBox, CheckBoxHandler>();
|
||||
handlers.AddHandler<ILayout, LayoutHandler>();
|
||||
handlers.AddHandler<IStackLayout, StackLayoutHandler>();
|
||||
handlers.AddHandler<IGridLayout, GridHandler>();
|
||||
// Application handler
|
||||
handlers.AddHandler<IApplication, ApplicationHandler>();
|
||||
|
||||
// Phase 2 - Input controls
|
||||
handlers.AddHandler<ISlider, SliderHandler>();
|
||||
handlers.AddHandler<ISwitch, SwitchHandler>();
|
||||
handlers.AddHandler<IProgress, ProgressBarHandler>();
|
||||
handlers.AddHandler<IActivityIndicator, ActivityIndicatorHandler>();
|
||||
handlers.AddHandler<ISearchBar, SearchBarHandler>();
|
||||
// Core controls
|
||||
handlers.AddHandler<BoxView, BoxViewHandler>();
|
||||
handlers.AddHandler<Button, TextButtonHandler>();
|
||||
handlers.AddHandler<Label, LabelHandler>();
|
||||
handlers.AddHandler<Entry, EntryHandler>();
|
||||
handlers.AddHandler<Editor, EditorHandler>();
|
||||
handlers.AddHandler<CheckBox, CheckBoxHandler>();
|
||||
handlers.AddHandler<Switch, SwitchHandler>();
|
||||
handlers.AddHandler<Slider, SliderHandler>();
|
||||
handlers.AddHandler<Stepper, StepperHandler>();
|
||||
handlers.AddHandler<RadioButton, RadioButtonHandler>();
|
||||
|
||||
// Phase 2 - Image & Graphics
|
||||
handlers.AddHandler<IImage, ImageHandler>();
|
||||
handlers.AddHandler<IImageButton, ImageButtonHandler>();
|
||||
handlers.AddHandler<IGraphicsView, GraphicsViewHandler>();
|
||||
// Layout controls
|
||||
handlers.AddHandler<Grid, GridHandler>();
|
||||
handlers.AddHandler<StackLayout, StackLayoutHandler>();
|
||||
handlers.AddHandler<VerticalStackLayout, StackLayoutHandler>();
|
||||
handlers.AddHandler<HorizontalStackLayout, StackLayoutHandler>();
|
||||
handlers.AddHandler<AbsoluteLayout, LayoutHandler>();
|
||||
handlers.AddHandler<FlexLayout, LayoutHandler>();
|
||||
handlers.AddHandler<ScrollView, ScrollViewHandler>();
|
||||
handlers.AddHandler<Frame, FrameHandler>();
|
||||
handlers.AddHandler<Border, BorderHandler>();
|
||||
handlers.AddHandler<ContentView, BorderHandler>();
|
||||
|
||||
// Phase 3 - Collection Views
|
||||
// Picker controls
|
||||
handlers.AddHandler<Picker, PickerHandler>();
|
||||
handlers.AddHandler<DatePicker, DatePickerHandler>();
|
||||
handlers.AddHandler<TimePicker, TimePickerHandler>();
|
||||
handlers.AddHandler<SearchBar, SearchBarHandler>();
|
||||
|
||||
// Progress & Activity
|
||||
handlers.AddHandler<ProgressBar, ProgressBarHandler>();
|
||||
handlers.AddHandler<ActivityIndicator, ActivityIndicatorHandler>();
|
||||
|
||||
// Image & Graphics
|
||||
handlers.AddHandler<Image, ImageHandler>();
|
||||
handlers.AddHandler<ImageButton, ImageButtonHandler>();
|
||||
handlers.AddHandler<GraphicsView, GraphicsViewHandler>();
|
||||
|
||||
// Collection Views
|
||||
handlers.AddHandler<CollectionView, CollectionViewHandler>();
|
||||
handlers.AddHandler<ListView, CollectionViewHandler>();
|
||||
|
||||
// Phase 4 - Pages & Navigation
|
||||
// Pages & Navigation
|
||||
handlers.AddHandler<Page, PageHandler>();
|
||||
handlers.AddHandler<ContentPage, ContentPageHandler>();
|
||||
handlers.AddHandler<NavigationPage, NavigationPageHandler>();
|
||||
handlers.AddHandler<Shell, ShellHandler>();
|
||||
handlers.AddHandler<FlyoutPage, FlyoutPageHandler>();
|
||||
handlers.AddHandler<TabbedPage, TabbedPageHandler>();
|
||||
|
||||
// Phase 5 - Advanced Controls
|
||||
handlers.AddHandler<IPicker, PickerHandler>();
|
||||
handlers.AddHandler<IDatePicker, DatePickerHandler>();
|
||||
handlers.AddHandler<ITimePicker, TimePickerHandler>();
|
||||
handlers.AddHandler<IEditor, EditorHandler>();
|
||||
|
||||
// Phase 7 - Additional Controls
|
||||
handlers.AddHandler<IStepper, StepperHandler>();
|
||||
handlers.AddHandler<IRadioButton, RadioButtonHandler>();
|
||||
handlers.AddHandler<IBorderView, BorderHandler>();
|
||||
|
||||
// Window handler
|
||||
handlers.AddHandler<IWindow, WindowHandler>();
|
||||
// Application & Window
|
||||
handlers.AddHandler<Application, ApplicationHandler>();
|
||||
handlers.AddHandler<Microsoft.Maui.Controls.Window, WindowHandler>();
|
||||
});
|
||||
|
||||
// Store options for later use
|
||||
|
|
@ -99,6 +120,18 @@ public static class LinuxMauiAppBuilderExtensions
|
|||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers custom type converters for Linux platform.
|
||||
/// </summary>
|
||||
private static void RegisterTypeConverters()
|
||||
{
|
||||
// Register SkiaSharp type converters for XAML styling support
|
||||
TypeDescriptor.AddAttributes(typeof(SKColor), new TypeConverterAttribute(typeof(SKColorTypeConverter)));
|
||||
TypeDescriptor.AddAttributes(typeof(SKRect), new TypeConverterAttribute(typeof(SKRectTypeConverter)));
|
||||
TypeDescriptor.AddAttributes(typeof(SKSize), new TypeConverterAttribute(typeof(SKSizeTypeConverter)));
|
||||
TypeDescriptor.AddAttributes(typeof(SKPoint), new TypeConverterAttribute(typeof(SKPointTypeConverter)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Animations;
|
||||
using Microsoft.Maui.Dispatching;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Linux-specific implementation of IMauiContext.
|
||||
/// Provides the infrastructure for creating handlers and accessing platform services.
|
||||
/// </summary>
|
||||
public class LinuxMauiContext : IMauiContext
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IMauiHandlersFactory _handlers;
|
||||
private readonly LinuxApplication _linuxApp;
|
||||
private IAnimationManager? _animationManager;
|
||||
private IDispatcher? _dispatcher;
|
||||
|
||||
public LinuxMauiContext(IServiceProvider services, LinuxApplication linuxApp)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_linuxApp = linuxApp ?? throw new ArgumentNullException(nameof(linuxApp));
|
||||
_handlers = services.GetRequiredService<IMauiHandlersFactory>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IServiceProvider Services => _services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMauiHandlersFactory Handlers => _handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Linux application instance.
|
||||
/// </summary>
|
||||
public LinuxApplication LinuxApp => _linuxApp;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the animation manager.
|
||||
/// </summary>
|
||||
public IAnimationManager AnimationManager
|
||||
{
|
||||
get
|
||||
{
|
||||
_animationManager ??= _services.GetService<IAnimationManager>()
|
||||
?? new LinuxAnimationManager(new LinuxTicker());
|
||||
return _animationManager;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dispatcher for UI thread operations.
|
||||
/// </summary>
|
||||
public IDispatcher Dispatcher
|
||||
{
|
||||
get
|
||||
{
|
||||
_dispatcher ??= _services.GetService<IDispatcher>()
|
||||
?? new LinuxDispatcher();
|
||||
return _dispatcher;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoped MAUI context for a specific window or view hierarchy.
|
||||
/// </summary>
|
||||
public class ScopedLinuxMauiContext : IMauiContext
|
||||
{
|
||||
private readonly LinuxMauiContext _parent;
|
||||
|
||||
public ScopedLinuxMauiContext(LinuxMauiContext parent)
|
||||
{
|
||||
_parent = parent ?? throw new ArgumentNullException(nameof(parent));
|
||||
}
|
||||
|
||||
public IServiceProvider Services => _parent.Services;
|
||||
public IMauiHandlersFactory Handlers => _parent.Handlers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linux dispatcher for UI thread operations.
|
||||
/// </summary>
|
||||
internal class LinuxDispatcher : IDispatcher
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Queue<Action> _queue = new();
|
||||
private bool _isDispatching;
|
||||
|
||||
public bool IsDispatchRequired => false; // Linux uses single-threaded event loop
|
||||
|
||||
public IDispatcherTimer CreateTimer()
|
||||
{
|
||||
return new LinuxDispatcherTimer();
|
||||
}
|
||||
|
||||
public bool Dispatch(Action action)
|
||||
{
|
||||
if (action == null)
|
||||
return false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_queue.Enqueue(action);
|
||||
}
|
||||
|
||||
ProcessQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DispatchDelayed(TimeSpan delay, Action action)
|
||||
{
|
||||
if (action == null)
|
||||
return false;
|
||||
|
||||
Task.Delay(delay).ContinueWith(_ => Dispatch(action));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ProcessQueue()
|
||||
{
|
||||
if (_isDispatching)
|
||||
return;
|
||||
|
||||
_isDispatching = true;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Action? action;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queue.Count == 0)
|
||||
break;
|
||||
action = _queue.Dequeue();
|
||||
}
|
||||
action?.Invoke();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isDispatching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linux dispatcher timer implementation.
|
||||
/// </summary>
|
||||
internal class LinuxDispatcherTimer : IDispatcherTimer
|
||||
{
|
||||
private Timer? _timer;
|
||||
private TimeSpan _interval = TimeSpan.FromMilliseconds(16); // ~60fps default
|
||||
private bool _isRunning;
|
||||
private bool _isRepeating = true;
|
||||
|
||||
public TimeSpan Interval
|
||||
{
|
||||
get => _interval;
|
||||
set => _interval = value;
|
||||
}
|
||||
|
||||
public bool IsRunning => _isRunning;
|
||||
|
||||
public bool IsRepeating
|
||||
{
|
||||
get => _isRepeating;
|
||||
set => _isRepeating = value;
|
||||
}
|
||||
|
||||
public event EventHandler? Tick;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning)
|
||||
return;
|
||||
|
||||
_isRunning = true;
|
||||
_timer = new Timer(OnTimerCallback, null, _interval, _isRepeating ? _interval : Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private void OnTimerCallback(object? state)
|
||||
{
|
||||
Tick?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
if (!_isRepeating)
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linux animation manager.
|
||||
/// </summary>
|
||||
internal class LinuxAnimationManager : IAnimationManager
|
||||
{
|
||||
private readonly List<Microsoft.Maui.Animations.Animation> _animations = new();
|
||||
private readonly ITicker _ticker;
|
||||
|
||||
public LinuxAnimationManager(ITicker ticker)
|
||||
{
|
||||
_ticker = ticker;
|
||||
_ticker.Fire = OnTickerFire;
|
||||
}
|
||||
|
||||
public double SpeedModifier { get; set; } = 1.0;
|
||||
public bool AutoStartTicker { get; set; } = true;
|
||||
|
||||
public ITicker Ticker => _ticker;
|
||||
|
||||
public void Add(Microsoft.Maui.Animations.Animation animation)
|
||||
{
|
||||
_animations.Add(animation);
|
||||
|
||||
if (AutoStartTicker && !_ticker.IsRunning)
|
||||
{
|
||||
_ticker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(Microsoft.Maui.Animations.Animation animation)
|
||||
{
|
||||
_animations.Remove(animation);
|
||||
|
||||
if (_animations.Count == 0 && _ticker.IsRunning)
|
||||
{
|
||||
_ticker.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTickerFire()
|
||||
{
|
||||
var animations = _animations.ToArray();
|
||||
foreach (var animation in animations)
|
||||
{
|
||||
animation.Tick(16.0 / 1000.0 * SpeedModifier); // ~60fps
|
||||
if (animation.HasFinished)
|
||||
{
|
||||
Remove(animation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Linux ticker for animation timing.
|
||||
/// </summary>
|
||||
internal class LinuxTicker : ITicker
|
||||
{
|
||||
private Timer? _timer;
|
||||
private bool _isRunning;
|
||||
private int _maxFps = 60;
|
||||
|
||||
public bool IsRunning => _isRunning;
|
||||
|
||||
public bool SystemEnabled => true;
|
||||
|
||||
public int MaxFps
|
||||
{
|
||||
get => _maxFps;
|
||||
set => _maxFps = Math.Max(1, Math.Min(120, value));
|
||||
}
|
||||
|
||||
public Action? Fire { get; set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning)
|
||||
return;
|
||||
|
||||
_isRunning = true;
|
||||
var interval = TimeSpan.FromMilliseconds(1000.0 / _maxFps);
|
||||
_timer = new Timer(OnTimerCallback, null, TimeSpan.Zero, interval);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
private void OnTimerCallback(object? state)
|
||||
{
|
||||
Fire?.Invoke();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,39 +4,151 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for running MAUI applications on Linux.
|
||||
/// </summary>
|
||||
public static class LinuxProgramHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the MAUI application on Linux.
|
||||
/// </summary>
|
||||
/// <typeparam name="TApp">The application type.</typeparam>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
public static void Run<TApp>(string[] args) where TApp : class, IApplication, new()
|
||||
{
|
||||
Run<TApp>(args, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the MAUI application on Linux with additional configuration.
|
||||
/// </summary>
|
||||
/// <typeparam name="TApp">The application type.</typeparam>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
/// <param name="configure">Optional builder configuration action.</param>
|
||||
public static void Run<TApp>(string[] args, Action<MauiAppBuilder>? configure) where TApp : class, IApplication, new()
|
||||
{
|
||||
// Build the MAUI application
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder.UseLinux();
|
||||
configure?.Invoke(builder);
|
||||
builder.UseMauiApp<TApp>();
|
||||
var mauiApp = builder.Build();
|
||||
|
||||
// Get application options
|
||||
var options = mauiApp.Services.GetService<LinuxApplicationOptions>()
|
||||
?? new LinuxApplicationOptions();
|
||||
ParseCommandLineOptions(args, options);
|
||||
|
||||
// Create Linux application
|
||||
using var linuxApp = new LinuxApplication();
|
||||
linuxApp.Initialize(options);
|
||||
|
||||
// Create comprehensive demo UI with ALL controls
|
||||
var rootView = CreateComprehensiveDemo();
|
||||
linuxApp.RootView = rootView;
|
||||
// Create MAUI context
|
||||
var mauiContext = new LinuxMauiContext(mauiApp.Services, linuxApp);
|
||||
|
||||
// Get the MAUI application instance
|
||||
var application = mauiApp.Services.GetService<IApplication>();
|
||||
|
||||
// Ensure Application.Current is set - required for Shell.Current to work
|
||||
if (application is Application app && Application.Current == null)
|
||||
{
|
||||
// Use reflection to set Current since it has a protected setter
|
||||
var currentProperty = typeof(Application).GetProperty("Current");
|
||||
currentProperty?.SetValue(null, app);
|
||||
}
|
||||
|
||||
// Try to render the application's main page
|
||||
SkiaView? rootView = null;
|
||||
|
||||
if (application != null)
|
||||
{
|
||||
rootView = RenderApplication(application, mauiContext, options);
|
||||
}
|
||||
|
||||
// Fallback to demo if no application view is available
|
||||
if (rootView == null)
|
||||
{
|
||||
Console.WriteLine("No application page found. Showing demo UI.");
|
||||
rootView = CreateDemoView();
|
||||
}
|
||||
|
||||
linuxApp.RootView = rootView;
|
||||
linuxApp.Run();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the MAUI application and returns the root SkiaView.
|
||||
/// </summary>
|
||||
private static SkiaView? RenderApplication(IApplication application, LinuxMauiContext mauiContext, LinuxApplicationOptions options)
|
||||
{
|
||||
try
|
||||
{
|
||||
// For Applications, we need to create a window
|
||||
if (application is Application app)
|
||||
{
|
||||
Page? mainPage = app.MainPage;
|
||||
|
||||
// If no MainPage set, check for windows
|
||||
if (mainPage == null && application.Windows.Count > 0)
|
||||
{
|
||||
var existingWindow = application.Windows[0];
|
||||
if (existingWindow.Content is Page page)
|
||||
{
|
||||
mainPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
if (mainPage != null)
|
||||
{
|
||||
// Create a MAUI Window and add it to the application
|
||||
// This ensures Shell.Current works properly (it reads from Application.Current.Windows[0].Page)
|
||||
if (app.Windows.Count == 0)
|
||||
{
|
||||
var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage);
|
||||
|
||||
// Try OpenWindow first
|
||||
app.OpenWindow(mauiWindow);
|
||||
|
||||
// If that didn't work, use reflection to add directly to _windows
|
||||
if (app.Windows.Count == 0)
|
||||
{
|
||||
var windowsField = typeof(Application).GetField("_windows",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (windowsField?.GetValue(app) is System.Collections.IList windowsList)
|
||||
{
|
||||
windowsList.Add(mauiWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RenderPage(mainPage, mauiContext);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error rendering application: {ex.Message}");
|
||||
Console.WriteLine(ex.StackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a MAUI Page to a SkiaView.
|
||||
/// </summary>
|
||||
private static SkiaView? RenderPage(Page page, LinuxMauiContext mauiContext)
|
||||
{
|
||||
var renderer = new LinuxViewRenderer(mauiContext);
|
||||
return renderer.RenderPage(page);
|
||||
}
|
||||
|
||||
private static void ParseCommandLineOptions(string[] args, LinuxApplicationOptions options)
|
||||
{
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
|
|
@ -54,15 +166,22 @@ public static class LinuxProgramHost
|
|||
options.Height = h;
|
||||
i++;
|
||||
break;
|
||||
case "--demo":
|
||||
// Force demo mode
|
||||
options.ForceDemo = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SkiaView CreateComprehensiveDemo()
|
||||
/// <summary>
|
||||
/// Creates a demo view showcasing all controls.
|
||||
/// </summary>
|
||||
public static SkiaView CreateDemoView()
|
||||
{
|
||||
// Create scrollable container
|
||||
var scroll = new SkiaScrollView();
|
||||
|
||||
|
||||
var root = new SkiaStackLayout
|
||||
{
|
||||
Orientation = StackOrientation.Vertical,
|
||||
|
|
@ -72,18 +191,18 @@ public static class LinuxProgramHost
|
|||
root.Padding = new SKRect(20, 20, 20, 20);
|
||||
|
||||
// ========== TITLE ==========
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "MAUI Linux Control Demo",
|
||||
FontSize = 28,
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "OpenMaui Linux Control Demo",
|
||||
FontSize = 28,
|
||||
TextColor = new SKColor(0x1A, 0x23, 0x7E),
|
||||
IsBold = true
|
||||
});
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "All controls rendered using SkiaSharp on X11",
|
||||
FontSize = 14,
|
||||
TextColor = SKColors.Gray
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "All controls rendered using SkiaSharp on X11",
|
||||
FontSize = 14,
|
||||
TextColor = SKColors.Gray
|
||||
});
|
||||
|
||||
// ========== LABELS SECTION ==========
|
||||
|
|
@ -100,7 +219,7 @@ public static class LinuxProgramHost
|
|||
root.AddChild(CreateSeparator());
|
||||
root.AddChild(CreateSectionHeader("Buttons"));
|
||||
var buttonSection = new SkiaStackLayout { Orientation = StackOrientation.Horizontal, Spacing = 10 };
|
||||
|
||||
|
||||
var btnPrimary = new SkiaButton { Text = "Primary", FontSize = 14 };
|
||||
btnPrimary.BackgroundColor = new SKColor(0x21, 0x96, 0xF3);
|
||||
btnPrimary.TextColor = SKColors.White;
|
||||
|
|
@ -117,7 +236,7 @@ public static class LinuxProgramHost
|
|||
btnDanger.BackgroundColor = new SKColor(0xF4, 0x43, 0x36);
|
||||
btnDanger.TextColor = SKColors.White;
|
||||
buttonSection.AddChild(btnDanger);
|
||||
|
||||
|
||||
root.AddChild(buttonSection);
|
||||
|
||||
// ========== ENTRY SECTION ==========
|
||||
|
|
@ -139,9 +258,9 @@ public static class LinuxProgramHost
|
|||
// ========== EDITOR SECTION ==========
|
||||
root.AddChild(CreateSeparator());
|
||||
root.AddChild(CreateSectionHeader("Editor (Multi-line)"));
|
||||
var editor = new SkiaEditor
|
||||
{
|
||||
Placeholder = "Enter multiple lines of text...",
|
||||
var editor = new SkiaEditor
|
||||
{
|
||||
Placeholder = "Enter multiple lines of text...",
|
||||
FontSize = 14,
|
||||
BackgroundColor = SKColors.White
|
||||
};
|
||||
|
|
@ -277,7 +396,7 @@ public static class LinuxProgramHost
|
|||
};
|
||||
collectionView.ItemsSource =(new object[] { "Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape", "Honeydew" });
|
||||
var collectionLabel = new SkiaLabel { Text = "Selected: (none)", FontSize = 12, TextColor = SKColors.Gray };
|
||||
collectionView.SelectionChanged += (s, e) =>
|
||||
collectionView.SelectionChanged += (s, e) =>
|
||||
{
|
||||
var selected = e.CurrentSelection.FirstOrDefault();
|
||||
collectionLabel.Text = $"Selected: {selected}";
|
||||
|
|
@ -289,7 +408,7 @@ public static class LinuxProgramHost
|
|||
root.AddChild(CreateSeparator());
|
||||
root.AddChild(CreateSectionHeader("ImageButton"));
|
||||
var imageButtonSection = new SkiaStackLayout { Orientation = StackOrientation.Horizontal, Spacing = 10 };
|
||||
|
||||
|
||||
// Create ImageButton with a generated icon (since we don't have image files)
|
||||
var imgBtn = new SkiaImageButton
|
||||
{
|
||||
|
|
@ -315,7 +434,7 @@ public static class LinuxProgramHost
|
|||
root.AddChild(CreateSeparator());
|
||||
root.AddChild(CreateSectionHeader("Image"));
|
||||
var imageSection = new SkiaStackLayout { Orientation = StackOrientation.Horizontal, Spacing = 10 };
|
||||
|
||||
|
||||
// Create Image with a generated sample image
|
||||
var img = new SkiaImage();
|
||||
var sampleBitmap = CreateSampleImage(80, 60);
|
||||
|
|
@ -326,17 +445,17 @@ public static class LinuxProgramHost
|
|||
|
||||
// ========== FOOTER ==========
|
||||
root.AddChild(CreateSeparator());
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "All 25+ controls are interactive - try them all!",
|
||||
FontSize = 16,
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "All 25+ controls are interactive - try them all!",
|
||||
FontSize = 16,
|
||||
TextColor = new SKColor(0x4C, 0xAF, 0x50),
|
||||
IsBold = true
|
||||
});
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "Scroll down to see more controls",
|
||||
FontSize = 12,
|
||||
root.AddChild(new SkiaLabel
|
||||
{
|
||||
Text = "Scroll down to see more controls",
|
||||
FontSize = 12,
|
||||
TextColor = SKColors.Gray
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,497 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Renders MAUI views to Skia platform views.
|
||||
/// Handles the conversion of the view hierarchy.
|
||||
/// </summary>
|
||||
public class LinuxViewRenderer
|
||||
{
|
||||
private readonly IMauiContext _mauiContext;
|
||||
|
||||
/// <summary>
|
||||
/// Static reference to the current MAUI Shell for navigation support.
|
||||
/// Used when Shell.Current is not available through normal lifecycle.
|
||||
/// </summary>
|
||||
public static Shell? CurrentMauiShell { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Static reference to the current SkiaShell for navigation updates.
|
||||
/// </summary>
|
||||
public static SkiaShell? CurrentSkiaShell { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigate to a route using the SkiaShell directly.
|
||||
/// Use this instead of Shell.Current.GoToAsync on Linux.
|
||||
/// </summary>
|
||||
/// <param name="route">The route to navigate to (e.g., "Buttons" or "//Buttons")</param>
|
||||
/// <returns>True if navigation succeeded</returns>
|
||||
public static bool NavigateToRoute(string route)
|
||||
{
|
||||
if (CurrentSkiaShell == null)
|
||||
{
|
||||
Console.WriteLine($"[NavigateToRoute] CurrentSkiaShell is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up the route - remove leading // or /
|
||||
var cleanRoute = route.TrimStart('/');
|
||||
Console.WriteLine($"[NavigateToRoute] Navigating to: {cleanRoute}");
|
||||
|
||||
for (int i = 0; i < CurrentSkiaShell.Sections.Count; i++)
|
||||
{
|
||||
var section = CurrentSkiaShell.Sections[i];
|
||||
if (section.Route.Equals(cleanRoute, StringComparison.OrdinalIgnoreCase) ||
|
||||
section.Title.Equals(cleanRoute, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"[NavigateToRoute] Found section {i}: {section.Title}");
|
||||
CurrentSkiaShell.NavigateToSection(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[NavigateToRoute] Route not found: {cleanRoute}");
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current renderer instance for page rendering.
|
||||
/// </summary>
|
||||
public static LinuxViewRenderer? CurrentRenderer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a page onto the navigation stack.
|
||||
/// </summary>
|
||||
/// <param name="page">The page to push</param>
|
||||
/// <returns>True if successful</returns>
|
||||
public static bool PushPage(Page page)
|
||||
{
|
||||
Console.WriteLine($"[PushPage] Pushing page: {page.GetType().Name}");
|
||||
|
||||
if (CurrentSkiaShell == null)
|
||||
{
|
||||
Console.WriteLine($"[PushPage] CurrentSkiaShell is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (CurrentRenderer == null)
|
||||
{
|
||||
Console.WriteLine($"[PushPage] CurrentRenderer is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Render the page content
|
||||
SkiaView? pageContent = null;
|
||||
if (page is ContentPage contentPage && contentPage.Content != null)
|
||||
{
|
||||
pageContent = CurrentRenderer.RenderView(contentPage.Content);
|
||||
}
|
||||
|
||||
if (pageContent == null)
|
||||
{
|
||||
Console.WriteLine($"[PushPage] Failed to render page content");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wrap in ScrollView if needed
|
||||
if (pageContent is not SkiaScrollView)
|
||||
{
|
||||
var scrollView = new SkiaScrollView { Content = pageContent };
|
||||
pageContent = scrollView;
|
||||
}
|
||||
|
||||
// Push onto SkiaShell's navigation stack
|
||||
CurrentSkiaShell.PushAsync(pageContent, page.Title ?? "Detail");
|
||||
Console.WriteLine($"[PushPage] Successfully pushed page");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[PushPage] Error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops the current page from the navigation stack.
|
||||
/// </summary>
|
||||
/// <returns>True if successful</returns>
|
||||
public static bool PopPage()
|
||||
{
|
||||
Console.WriteLine($"[PopPage] Popping page");
|
||||
|
||||
if (CurrentSkiaShell == null)
|
||||
{
|
||||
Console.WriteLine($"[PopPage] CurrentSkiaShell is null");
|
||||
return false;
|
||||
}
|
||||
|
||||
return CurrentSkiaShell.PopAsync();
|
||||
}
|
||||
|
||||
public LinuxViewRenderer(IMauiContext mauiContext)
|
||||
{
|
||||
_mauiContext = mauiContext ?? throw new ArgumentNullException(nameof(mauiContext));
|
||||
// Store reference for push/pop navigation
|
||||
CurrentRenderer = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a MAUI page and returns the corresponding SkiaView.
|
||||
/// </summary>
|
||||
public SkiaView? RenderPage(Page page)
|
||||
{
|
||||
if (page == null)
|
||||
return null;
|
||||
|
||||
// Special handling for Shell - Shell is our navigation container
|
||||
if (page is Shell shell)
|
||||
{
|
||||
return RenderShell(shell);
|
||||
}
|
||||
|
||||
// Set handler context
|
||||
page.Handler?.DisconnectHandler();
|
||||
var handler = page.ToHandler(_mauiContext);
|
||||
|
||||
if (handler.PlatformView is SkiaView skiaPage)
|
||||
{
|
||||
// For ContentPage, render the content
|
||||
if (page is ContentPage contentPage && contentPage.Content != null)
|
||||
{
|
||||
var contentView = RenderView(contentPage.Content);
|
||||
if (skiaPage is SkiaPage sp && contentView != null)
|
||||
{
|
||||
sp.Content = contentView;
|
||||
}
|
||||
}
|
||||
|
||||
return skiaPage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a MAUI Shell with all its navigation structure.
|
||||
/// </summary>
|
||||
private SkiaShell RenderShell(Shell shell)
|
||||
{
|
||||
// Store reference for navigation - Shell.Current is computed from Application.Current.Windows
|
||||
// Our platform handles navigation through SkiaShell directly
|
||||
CurrentMauiShell = shell;
|
||||
|
||||
var skiaShell = new SkiaShell
|
||||
{
|
||||
Title = shell.Title ?? "App",
|
||||
FlyoutBehavior = shell.FlyoutBehavior switch
|
||||
{
|
||||
FlyoutBehavior.Flyout => ShellFlyoutBehavior.Flyout,
|
||||
FlyoutBehavior.Locked => ShellFlyoutBehavior.Locked,
|
||||
FlyoutBehavior.Disabled => ShellFlyoutBehavior.Disabled,
|
||||
_ => ShellFlyoutBehavior.Flyout
|
||||
}
|
||||
};
|
||||
|
||||
// Process shell items into sections
|
||||
foreach (var item in shell.Items)
|
||||
{
|
||||
ProcessShellItem(skiaShell, item);
|
||||
}
|
||||
|
||||
// Store reference to SkiaShell for navigation
|
||||
CurrentSkiaShell = skiaShell;
|
||||
|
||||
// Subscribe to MAUI Shell navigation events to update SkiaShell
|
||||
shell.Navigated += OnShellNavigated;
|
||||
shell.Navigating += (s, e) => Console.WriteLine($"[Navigation] Navigating: {e.Target}");
|
||||
|
||||
Console.WriteLine($"[Navigation] Shell navigation events subscribed. Sections: {skiaShell.Sections.Count}");
|
||||
for (int i = 0; i < skiaShell.Sections.Count; i++)
|
||||
{
|
||||
Console.WriteLine($"[Navigation] Section {i}: Route='{skiaShell.Sections[i].Route}', Title='{skiaShell.Sections[i].Title}'");
|
||||
}
|
||||
|
||||
return skiaShell;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles MAUI Shell navigation events and updates SkiaShell accordingly.
|
||||
/// </summary>
|
||||
private static void OnShellNavigated(object? sender, ShellNavigatedEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[Navigation] OnShellNavigated called - Source: {e.Source}, Current: {e.Current?.Location}, Previous: {e.Previous?.Location}");
|
||||
|
||||
if (CurrentSkiaShell == null || CurrentMauiShell == null)
|
||||
{
|
||||
Console.WriteLine($"[Navigation] CurrentSkiaShell or CurrentMauiShell is null");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current route from the Shell
|
||||
var currentState = CurrentMauiShell.CurrentState;
|
||||
var location = currentState?.Location?.OriginalString ?? "";
|
||||
Console.WriteLine($"[Navigation] Location: {location}, Sections: {CurrentSkiaShell.Sections.Count}");
|
||||
|
||||
// Find the matching section in SkiaShell by route
|
||||
for (int i = 0; i < CurrentSkiaShell.Sections.Count; i++)
|
||||
{
|
||||
var section = CurrentSkiaShell.Sections[i];
|
||||
Console.WriteLine($"[Navigation] Checking section {i}: Route='{section.Route}', Title='{section.Title}'");
|
||||
if (!string.IsNullOrEmpty(section.Route) && location.Contains(section.Route, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"[Navigation] Match found by route! Navigating to section {i}");
|
||||
if (i != CurrentSkiaShell.CurrentSectionIndex)
|
||||
{
|
||||
CurrentSkiaShell.NavigateToSection(i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(section.Title) && location.Contains(section.Title, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"[Navigation] Match found by title! Navigating to section {i}");
|
||||
if (i != CurrentSkiaShell.CurrentSectionIndex)
|
||||
{
|
||||
CurrentSkiaShell.NavigateToSection(i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[Navigation] No matching section found for location: {location}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a ShellItem (FlyoutItem, TabBar, etc.) into SkiaShell sections.
|
||||
/// </summary>
|
||||
private void ProcessShellItem(SkiaShell skiaShell, ShellItem item)
|
||||
{
|
||||
if (item is FlyoutItem flyoutItem)
|
||||
{
|
||||
// Each FlyoutItem becomes a section
|
||||
var section = new ShellSection
|
||||
{
|
||||
Title = flyoutItem.Title ?? "",
|
||||
Route = flyoutItem.Route ?? flyoutItem.Title ?? ""
|
||||
};
|
||||
|
||||
// Process the items within the FlyoutItem
|
||||
foreach (var shellSection in flyoutItem.Items)
|
||||
{
|
||||
foreach (var content in shellSection.Items)
|
||||
{
|
||||
var shellContent = new ShellContent
|
||||
{
|
||||
Title = content.Title ?? shellSection.Title ?? flyoutItem.Title ?? "",
|
||||
Route = content.Route ?? ""
|
||||
};
|
||||
|
||||
// Create the page content
|
||||
var pageContent = CreateShellContentPage(content);
|
||||
if (pageContent != null)
|
||||
{
|
||||
shellContent.Content = pageContent;
|
||||
}
|
||||
|
||||
section.Items.Add(shellContent);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's only one item, use it as the main section content
|
||||
if (section.Items.Count == 1)
|
||||
{
|
||||
section.Title = section.Items[0].Title;
|
||||
}
|
||||
|
||||
skiaShell.AddSection(section);
|
||||
}
|
||||
else if (item is TabBar tabBar)
|
||||
{
|
||||
// TabBar items get their own sections
|
||||
foreach (var tab in tabBar.Items)
|
||||
{
|
||||
var section = new ShellSection
|
||||
{
|
||||
Title = tab.Title ?? "",
|
||||
Route = tab.Route ?? ""
|
||||
};
|
||||
|
||||
foreach (var content in tab.Items)
|
||||
{
|
||||
var shellContent = new ShellContent
|
||||
{
|
||||
Title = content.Title ?? tab.Title ?? "",
|
||||
Route = content.Route ?? ""
|
||||
};
|
||||
|
||||
var pageContent = CreateShellContentPage(content);
|
||||
if (pageContent != null)
|
||||
{
|
||||
shellContent.Content = pageContent;
|
||||
}
|
||||
|
||||
section.Items.Add(shellContent);
|
||||
}
|
||||
|
||||
skiaShell.AddSection(section);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generic ShellItem
|
||||
var section = new ShellSection
|
||||
{
|
||||
Title = item.Title ?? "",
|
||||
Route = item.Route ?? ""
|
||||
};
|
||||
|
||||
foreach (var shellSection in item.Items)
|
||||
{
|
||||
foreach (var content in shellSection.Items)
|
||||
{
|
||||
var shellContent = new ShellContent
|
||||
{
|
||||
Title = content.Title ?? "",
|
||||
Route = content.Route ?? ""
|
||||
};
|
||||
|
||||
var pageContent = CreateShellContentPage(content);
|
||||
if (pageContent != null)
|
||||
{
|
||||
shellContent.Content = pageContent;
|
||||
}
|
||||
|
||||
section.Items.Add(shellContent);
|
||||
}
|
||||
}
|
||||
|
||||
skiaShell.AddSection(section);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the page content for a ShellContent.
|
||||
/// </summary>
|
||||
private SkiaView? CreateShellContentPage(Controls.ShellContent content)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to create the page from the content template
|
||||
Page? page = null;
|
||||
|
||||
if (content.ContentTemplate != null)
|
||||
{
|
||||
page = content.ContentTemplate.CreateContent() as Page;
|
||||
}
|
||||
|
||||
if (page == null && content.Content is Page contentPage)
|
||||
{
|
||||
page = contentPage;
|
||||
}
|
||||
|
||||
if (page is ContentPage cp && cp.Content != null)
|
||||
{
|
||||
// Wrap in a scroll view if not already scrollable
|
||||
var contentView = RenderView(cp.Content);
|
||||
if (contentView != null)
|
||||
{
|
||||
if (contentView is SkiaScrollView)
|
||||
{
|
||||
return contentView;
|
||||
}
|
||||
else
|
||||
{
|
||||
var scrollView = new SkiaScrollView
|
||||
{
|
||||
Content = contentView
|
||||
};
|
||||
return scrollView;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Silently handle template creation errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a MAUI view and returns the corresponding SkiaView.
|
||||
/// </summary>
|
||||
public SkiaView? RenderView(IView view)
|
||||
{
|
||||
if (view == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Disconnect any existing handler
|
||||
if (view is Element element && element.Handler != null)
|
||||
{
|
||||
element.Handler.DisconnectHandler();
|
||||
}
|
||||
|
||||
// Create handler for the view
|
||||
// The handler's ConnectHandler and property mappers handle child views automatically
|
||||
var handler = view.ToHandler(_mauiContext);
|
||||
|
||||
if (handler?.PlatformView is not SkiaView skiaView)
|
||||
{
|
||||
// If no Skia handler, create a fallback
|
||||
return CreateFallbackView(view);
|
||||
}
|
||||
|
||||
// Handlers manage their own children via ConnectHandler and property mappers
|
||||
// No manual child rendering needed here - that caused "View already has a parent" errors
|
||||
return skiaView;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return CreateFallbackView(view);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fallback view for unsupported view types.
|
||||
/// </summary>
|
||||
private SkiaView CreateFallbackView(IView view)
|
||||
{
|
||||
// For views without handlers, create a placeholder
|
||||
return new SkiaLabel
|
||||
{
|
||||
Text = $"[{view.GetType().Name}]",
|
||||
TextColor = SKColors.Gray,
|
||||
FontSize = 12
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for MAUI handler creation.
|
||||
/// </summary>
|
||||
public static class MauiHandlerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a handler for the view and returns it.
|
||||
/// </summary>
|
||||
public static IElementHandler ToHandler(this IElement element, IMauiContext mauiContext)
|
||||
{
|
||||
var handler = mauiContext.Handlers.GetHandler(element.GetType());
|
||||
if (handler != null)
|
||||
{
|
||||
handler.SetMauiContext(mauiContext);
|
||||
handler.SetVirtualView(element);
|
||||
}
|
||||
return handler!;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// Copyright (c) 2025 MarketAlly LLC
|
||||
|
||||
using Microsoft.Maui;
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Hosting;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
using Microsoft.Maui.Platform.Linux.Handlers;
|
||||
|
||||
namespace OpenMaui.Platform.Linux.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring OpenMaui Linux platform in a MAUI application.
|
||||
/// This enables full XAML support by registering Linux-specific handlers.
|
||||
/// </summary>
|
||||
public static class MauiAppBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the application to use OpenMaui Linux platform with full XAML support.
|
||||
/// </summary>
|
||||
/// <param name="builder">The MAUI app builder.</param>
|
||||
/// <returns>The configured MAUI app builder.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var builder = MauiApp.CreateBuilder();
|
||||
/// builder
|
||||
/// .UseMauiApp<App>()
|
||||
/// .UseOpenMauiLinux(); // Enable Linux support with XAML
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static MauiAppBuilder UseOpenMauiLinux(this MauiAppBuilder builder)
|
||||
{
|
||||
builder.ConfigureMauiHandlers(handlers =>
|
||||
{
|
||||
// Register all Linux platform handlers
|
||||
// These map MAUI virtual views to our Skia platform views
|
||||
|
||||
// Basic Controls
|
||||
handlers.AddHandler<Button, ButtonHandler>();
|
||||
handlers.AddHandler<Label, LabelHandler>();
|
||||
handlers.AddHandler<Entry, EntryHandler>();
|
||||
handlers.AddHandler<Editor, EditorHandler>();
|
||||
handlers.AddHandler<CheckBox, CheckBoxHandler>();
|
||||
handlers.AddHandler<Switch, SwitchHandler>();
|
||||
handlers.AddHandler<RadioButton, RadioButtonHandler>();
|
||||
|
||||
// Selection Controls
|
||||
handlers.AddHandler<Slider, SliderHandler>();
|
||||
handlers.AddHandler<Stepper, StepperHandler>();
|
||||
handlers.AddHandler<Picker, PickerHandler>();
|
||||
handlers.AddHandler<DatePicker, DatePickerHandler>();
|
||||
handlers.AddHandler<TimePicker, TimePickerHandler>();
|
||||
|
||||
// Display Controls
|
||||
handlers.AddHandler<Image, ImageHandler>();
|
||||
handlers.AddHandler<ImageButton, ImageButtonHandler>();
|
||||
handlers.AddHandler<ActivityIndicator, ActivityIndicatorHandler>();
|
||||
handlers.AddHandler<ProgressBar, ProgressBarHandler>();
|
||||
|
||||
// Layout Controls
|
||||
handlers.AddHandler<Border, BorderHandler>();
|
||||
|
||||
// Collection Controls
|
||||
handlers.AddHandler<CollectionView, CollectionViewHandler>();
|
||||
|
||||
// Navigation Controls
|
||||
handlers.AddHandler<NavigationPage, NavigationPageHandler>();
|
||||
handlers.AddHandler<TabbedPage, TabbedPageHandler>();
|
||||
handlers.AddHandler<FlyoutPage, FlyoutPageHandler>();
|
||||
handlers.AddHandler<Shell, ShellHandler>();
|
||||
|
||||
// Page Controls
|
||||
handlers.AddHandler<Page, PageHandler>();
|
||||
handlers.AddHandler<ContentPage, PageHandler>();
|
||||
|
||||
// Graphics
|
||||
handlers.AddHandler<GraphicsView, GraphicsViewHandler>();
|
||||
|
||||
// Search
|
||||
handlers.AddHandler<SearchBar, SearchBarHandler>();
|
||||
|
||||
// Web
|
||||
handlers.AddHandler<WebView, WebViewHandler>();
|
||||
|
||||
// Window
|
||||
handlers.AddHandler<Window, WindowHandler>();
|
||||
});
|
||||
|
||||
// Register Linux-specific services
|
||||
builder.Services.AddSingleton<ILinuxPlatformServices, LinuxPlatformServices>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the application to use OpenMaui Linux with custom handler configuration.
|
||||
/// </summary>
|
||||
/// <param name="builder">The MAUI app builder.</param>
|
||||
/// <param name="configureHandlers">Action to configure additional handlers.</param>
|
||||
/// <returns>The configured MAUI app builder.</returns>
|
||||
public static MauiAppBuilder UseOpenMauiLinux(
|
||||
this MauiAppBuilder builder,
|
||||
Action<IMauiHandlersCollection>? configureHandlers)
|
||||
{
|
||||
builder.UseOpenMauiLinux();
|
||||
|
||||
if (configureHandlers != null)
|
||||
{
|
||||
builder.ConfigureMauiHandlers(configureHandlers);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Linux platform services.
|
||||
/// </summary>
|
||||
public interface ILinuxPlatformServices
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display server type (X11 or Wayland).
|
||||
/// </summary>
|
||||
DisplayServerType DisplayServer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current DPI scale factor.
|
||||
/// </summary>
|
||||
float ScaleFactor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether high contrast mode is enabled.
|
||||
/// </summary>
|
||||
bool IsHighContrastEnabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display server types supported by OpenMaui.
|
||||
/// </summary>
|
||||
public enum DisplayServerType
|
||||
{
|
||||
/// <summary>X11 display server.</summary>
|
||||
X11,
|
||||
/// <summary>Wayland display server.</summary>
|
||||
Wayland,
|
||||
/// <summary>Auto-detected display server.</summary>
|
||||
Auto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of Linux platform services.
|
||||
/// </summary>
|
||||
internal class LinuxPlatformServices : ILinuxPlatformServices
|
||||
{
|
||||
public DisplayServerType DisplayServer => DetectDisplayServer();
|
||||
public float ScaleFactor => DetectScaleFactor();
|
||||
public bool IsHighContrastEnabled => DetectHighContrast();
|
||||
|
||||
private static DisplayServerType DetectDisplayServer()
|
||||
{
|
||||
var waylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY");
|
||||
if (!string.IsNullOrEmpty(waylandDisplay))
|
||||
return DisplayServerType.Wayland;
|
||||
|
||||
var display = Environment.GetEnvironmentVariable("DISPLAY");
|
||||
if (!string.IsNullOrEmpty(display))
|
||||
return DisplayServerType.X11;
|
||||
|
||||
return DisplayServerType.Auto;
|
||||
}
|
||||
|
||||
private static float DetectScaleFactor()
|
||||
{
|
||||
// Try GDK_SCALE first
|
||||
var gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE");
|
||||
if (float.TryParse(gdkScale, out var scale))
|
||||
return scale;
|
||||
|
||||
// Default to 1.0
|
||||
return 1.0f;
|
||||
}
|
||||
|
||||
private static bool DetectHighContrast()
|
||||
{
|
||||
var highContrast = Environment.GetEnvironmentVariable("GTK_THEME");
|
||||
return highContrast?.Contains("HighContrast", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
}
|
||||
}
|
||||
|
|
@ -153,4 +153,154 @@ public static class KeyMapping
|
|||
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
// Linux evdev keycode to Key mapping (used by Wayland)
|
||||
private static readonly Dictionary<uint, Key> LinuxKeycodeToKey = new()
|
||||
{
|
||||
// Top row
|
||||
[1] = Key.Escape,
|
||||
[2] = Key.D1, [3] = Key.D2, [4] = Key.D3, [5] = Key.D4, [6] = Key.D5,
|
||||
[7] = Key.D6, [8] = Key.D7, [9] = Key.D8, [10] = Key.D9, [11] = Key.D0,
|
||||
[12] = Key.Minus, [13] = Key.Equals, [14] = Key.Backspace, [15] = Key.Tab,
|
||||
|
||||
// QWERTY row
|
||||
[16] = Key.Q, [17] = Key.W, [18] = Key.E, [19] = Key.R, [20] = Key.T,
|
||||
[21] = Key.Y, [22] = Key.U, [23] = Key.I, [24] = Key.O, [25] = Key.P,
|
||||
[26] = Key.LeftBracket, [27] = Key.RightBracket, [28] = Key.Enter,
|
||||
|
||||
// Control and ASDF row
|
||||
[29] = Key.Control,
|
||||
[30] = Key.A, [31] = Key.S, [32] = Key.D, [33] = Key.F, [34] = Key.G,
|
||||
[35] = Key.H, [36] = Key.J, [37] = Key.K, [38] = Key.L,
|
||||
[39] = Key.Semicolon, [40] = Key.Quote, [41] = Key.Grave,
|
||||
|
||||
// Shift and ZXCV row
|
||||
[42] = Key.Shift, [43] = Key.Backslash,
|
||||
[44] = Key.Z, [45] = Key.X, [46] = Key.C, [47] = Key.V, [48] = Key.B,
|
||||
[49] = Key.N, [50] = Key.M,
|
||||
[51] = Key.Comma, [52] = Key.Period, [53] = Key.Slash, [54] = Key.Shift,
|
||||
|
||||
// Bottom row
|
||||
[55] = Key.NumPadMultiply, [56] = Key.Alt, [57] = Key.Space,
|
||||
[58] = Key.CapsLock,
|
||||
|
||||
// Function keys
|
||||
[59] = Key.F1, [60] = Key.F2, [61] = Key.F3, [62] = Key.F4,
|
||||
[63] = Key.F5, [64] = Key.F6, [65] = Key.F7, [66] = Key.F8,
|
||||
[67] = Key.F9, [68] = Key.F10,
|
||||
|
||||
// NumLock and numpad
|
||||
[69] = Key.NumLock, [70] = Key.ScrollLock,
|
||||
[71] = Key.NumPad7, [72] = Key.NumPad8, [73] = Key.NumPad9, [74] = Key.NumPadSubtract,
|
||||
[75] = Key.NumPad4, [76] = Key.NumPad5, [77] = Key.NumPad6, [78] = Key.NumPadAdd,
|
||||
[79] = Key.NumPad1, [80] = Key.NumPad2, [81] = Key.NumPad3,
|
||||
[82] = Key.NumPad0, [83] = Key.NumPadDecimal,
|
||||
|
||||
// More function keys
|
||||
[87] = Key.F11, [88] = Key.F12,
|
||||
|
||||
// Extended keys
|
||||
[96] = Key.Enter, // NumPad Enter
|
||||
[97] = Key.Control, // Right Control
|
||||
[98] = Key.NumPadDivide,
|
||||
[99] = Key.PrintScreen,
|
||||
[100] = Key.Alt, // Right Alt
|
||||
[102] = Key.Home,
|
||||
[103] = Key.Up,
|
||||
[104] = Key.PageUp,
|
||||
[105] = Key.Left,
|
||||
[106] = Key.Right,
|
||||
[107] = Key.End,
|
||||
[108] = Key.Down,
|
||||
[109] = Key.PageDown,
|
||||
[110] = Key.Insert,
|
||||
[111] = Key.Delete,
|
||||
[119] = Key.Pause,
|
||||
[125] = Key.Super, // Left Super (Windows key)
|
||||
[126] = Key.Super, // Right Super
|
||||
[127] = Key.Menu,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Linux evdev keycode to a MAUI Key.
|
||||
/// Used for Wayland input where keycodes are offset by 8 from X11 keycodes.
|
||||
/// </summary>
|
||||
public static Key FromLinuxKeycode(uint keycode)
|
||||
{
|
||||
// Wayland uses evdev keycodes, X11 uses keycodes + 8
|
||||
// If caller added 8, subtract it
|
||||
var evdevCode = keycode >= 8 ? keycode - 8 : keycode;
|
||||
|
||||
if (LinuxKeycodeToKey.TryGetValue(evdevCode, out var key))
|
||||
return key;
|
||||
|
||||
return Key.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Key to its character representation, if applicable.
|
||||
/// </summary>
|
||||
public static char? ToChar(Key key, KeyModifiers modifiers)
|
||||
{
|
||||
bool shift = modifiers.HasFlag(KeyModifiers.Shift);
|
||||
bool capsLock = modifiers.HasFlag(KeyModifiers.CapsLock);
|
||||
bool upper = shift ^ capsLock;
|
||||
|
||||
// Letters
|
||||
if (key >= Key.A && key <= Key.Z)
|
||||
{
|
||||
char ch = (char)('a' + (key - Key.A));
|
||||
return upper ? char.ToUpper(ch) : ch;
|
||||
}
|
||||
|
||||
// Numbers (with shift gives symbols)
|
||||
if (key >= Key.D0 && key <= Key.D9)
|
||||
{
|
||||
if (shift)
|
||||
{
|
||||
return (key - Key.D0) switch
|
||||
{
|
||||
0 => ')',
|
||||
1 => '!',
|
||||
2 => '@',
|
||||
3 => '#',
|
||||
4 => '$',
|
||||
5 => '%',
|
||||
6 => '^',
|
||||
7 => '&',
|
||||
8 => '*',
|
||||
9 => '(',
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
return (char)('0' + (key - Key.D0));
|
||||
}
|
||||
|
||||
// NumPad numbers
|
||||
if (key >= Key.NumPad0 && key <= Key.NumPad9)
|
||||
return (char)('0' + (key - Key.NumPad0));
|
||||
|
||||
// Punctuation
|
||||
return key switch
|
||||
{
|
||||
Key.Space => ' ',
|
||||
Key.Comma => shift ? '<' : ',',
|
||||
Key.Period => shift ? '>' : '.',
|
||||
Key.Slash => shift ? '?' : '/',
|
||||
Key.Semicolon => shift ? ':' : ';',
|
||||
Key.Quote => shift ? '"' : '\'',
|
||||
Key.LeftBracket => shift ? '{' : '[',
|
||||
Key.RightBracket => shift ? '}' : ']',
|
||||
Key.Backslash => shift ? '|' : '\\',
|
||||
Key.Minus => shift ? '_' : '-',
|
||||
Key.Equals => shift ? '+' : '=',
|
||||
Key.Grave => shift ? '~' : '`',
|
||||
Key.NumPadAdd => '+',
|
||||
Key.NumPadSubtract => '-',
|
||||
Key.NumPadMultiply => '*',
|
||||
Key.NumPadDivide => '/',
|
||||
Key.NumPadDecimal => '.',
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Maui.Hosting;
|
||||
using Microsoft.Maui.Platform.Linux.Rendering;
|
||||
using Microsoft.Maui.Platform.Linux.Window;
|
||||
using Microsoft.Maui.Platform.Linux.Services;
|
||||
|
|
@ -18,6 +20,7 @@ public class LinuxApplication : IDisposable
|
|||
private SkiaView? _rootView;
|
||||
private SkiaView? _focusedView;
|
||||
private SkiaView? _hoveredView;
|
||||
private SkiaView? _capturedView; // View that has captured pointer events during drag
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -85,6 +88,129 @@ public class LinuxApplication : IDisposable
|
|||
public LinuxApplication()
|
||||
{
|
||||
Current = this;
|
||||
|
||||
// Set up dialog service invalidation callback
|
||||
LinuxDialogService.SetInvalidateCallback(() => _renderingEngine?.InvalidateAll());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a MAUI application on Linux.
|
||||
/// This is the main entry point for Linux apps.
|
||||
/// </summary>
|
||||
/// <param name="app">The MauiApp to run.</param>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
public static void Run(MauiApp app, string[] args)
|
||||
{
|
||||
Run(app, args, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a MAUI application on Linux with options.
|
||||
/// </summary>
|
||||
/// <param name="app">The MauiApp to run.</param>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
public static void Run(MauiApp app, string[] args, Action<LinuxApplicationOptions>? configure)
|
||||
{
|
||||
var options = app.Services.GetService<LinuxApplicationOptions>()
|
||||
?? new LinuxApplicationOptions();
|
||||
configure?.Invoke(options);
|
||||
ParseCommandLineOptions(args, options);
|
||||
|
||||
using var linuxApp = new LinuxApplication();
|
||||
linuxApp.Initialize(options);
|
||||
|
||||
// Create MAUI context
|
||||
var mauiContext = new Hosting.LinuxMauiContext(app.Services, linuxApp);
|
||||
|
||||
// Get the application and render it
|
||||
var application = app.Services.GetService<IApplication>();
|
||||
SkiaView? rootView = null;
|
||||
|
||||
if (application is Microsoft.Maui.Controls.Application mauiApplication)
|
||||
{
|
||||
// Force Application.Current to be this instance
|
||||
// The constructor sets Current = this, but we ensure it here
|
||||
var currentProperty = typeof(Microsoft.Maui.Controls.Application).GetProperty("Current");
|
||||
if (currentProperty != null && currentProperty.CanWrite)
|
||||
{
|
||||
currentProperty.SetValue(null, mauiApplication);
|
||||
}
|
||||
|
||||
if (mauiApplication.MainPage != null)
|
||||
{
|
||||
// Create a MAUI Window and add it to the application
|
||||
// This ensures Shell.Current works (it reads from Application.Current.Windows[0].Page)
|
||||
var mainPage = mauiApplication.MainPage;
|
||||
|
||||
// Always ensure we have a window with the Shell/Page
|
||||
var windowsField = typeof(Microsoft.Maui.Controls.Application).GetField("_windows",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var windowsList = windowsField?.GetValue(mauiApplication) as System.Collections.Generic.List<Microsoft.Maui.Controls.Window>;
|
||||
|
||||
if (windowsList != null && windowsList.Count == 0)
|
||||
{
|
||||
var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage);
|
||||
windowsList.Add(mauiWindow);
|
||||
mauiWindow.Parent = mauiApplication;
|
||||
}
|
||||
else if (windowsList != null && windowsList.Count > 0 && windowsList[0].Page == null)
|
||||
{
|
||||
// Window exists but has no page - set it
|
||||
windowsList[0].Page = mainPage;
|
||||
}
|
||||
|
||||
var renderer = new Hosting.LinuxViewRenderer(mauiContext);
|
||||
rootView = renderer.RenderPage(mainPage);
|
||||
|
||||
// Update window title based on app name (NavigationPage.Title takes precedence)
|
||||
string windowTitle = "OpenMaui App";
|
||||
if (mainPage is Microsoft.Maui.Controls.NavigationPage navPage)
|
||||
{
|
||||
// Prefer NavigationPage.Title (app name) over CurrentPage.Title (page name) for window title
|
||||
windowTitle = navPage.Title ?? windowTitle;
|
||||
}
|
||||
else if (mainPage is Microsoft.Maui.Controls.Shell shell)
|
||||
{
|
||||
windowTitle = shell.Title ?? windowTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
windowTitle = mainPage.Title ?? windowTitle;
|
||||
}
|
||||
linuxApp.SetWindowTitle(windowTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to demo if no view
|
||||
if (rootView == null)
|
||||
{
|
||||
rootView = Hosting.LinuxProgramHost.CreateDemoView();
|
||||
}
|
||||
|
||||
linuxApp.RootView = rootView;
|
||||
linuxApp.Run();
|
||||
}
|
||||
|
||||
private static void ParseCommandLineOptions(string[] args, LinuxApplicationOptions options)
|
||||
{
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i].ToLowerInvariant())
|
||||
{
|
||||
case "--title" when i + 1 < args.Length:
|
||||
options.Title = args[++i];
|
||||
break;
|
||||
case "--width" when i + 1 < args.Length && int.TryParse(args[i + 1], out var w):
|
||||
options.Width = w;
|
||||
i++;
|
||||
break;
|
||||
case "--height" when i + 1 < args.Length && int.TryParse(args[i + 1], out var h):
|
||||
options.Height = h;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -123,6 +249,14 @@ public class LinuxApplication : IDisposable
|
|||
// For now, we create singleton instances
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the window title.
|
||||
/// </summary>
|
||||
public void SetWindowTitle(string title)
|
||||
{
|
||||
_mainWindow?.SetTitle(title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the main window and runs the event loop.
|
||||
/// </summary>
|
||||
|
|
@ -171,6 +305,9 @@ public class LinuxApplication : IDisposable
|
|||
{
|
||||
if (_rootView != null)
|
||||
{
|
||||
// Re-measure with new available size, then arrange
|
||||
var availableSize = new SkiaSharp.SKSize(size.Width, size.Height);
|
||||
_rootView.Measure(availableSize);
|
||||
_rootView.Arrange(new SkiaSharp.SKRect(0, 0, size.Width, size.Height));
|
||||
}
|
||||
_renderingEngine?.InvalidateAll();
|
||||
|
|
@ -183,6 +320,13 @@ public class LinuxApplication : IDisposable
|
|||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
// Route to dialog if one is active
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.TopDialog?.OnKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_focusedView != null)
|
||||
{
|
||||
_focusedView.OnKeyDown(e);
|
||||
|
|
@ -191,6 +335,13 @@ public class LinuxApplication : IDisposable
|
|||
|
||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
||||
{
|
||||
// Route to dialog if one is active
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.TopDialog?.OnKeyUp(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_focusedView != null)
|
||||
{
|
||||
_focusedView.OnKeyUp(e);
|
||||
|
|
@ -207,10 +358,26 @@ public class LinuxApplication : IDisposable
|
|||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
// Route to dialog if one is active
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.TopDialog?.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_rootView != null)
|
||||
{
|
||||
var hitView = _rootView.HitTest(e.X, e.Y);
|
||||
|
||||
// If a view has captured the pointer, send all events to it
|
||||
if (_capturedView != null)
|
||||
{
|
||||
_capturedView.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for popup overlay first
|
||||
var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
|
||||
var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
|
||||
|
||||
// Track hover state changes
|
||||
if (hitView != _hoveredView)
|
||||
{
|
||||
|
|
@ -218,28 +385,50 @@ public class LinuxApplication : IDisposable
|
|||
_hoveredView = hitView;
|
||||
_hoveredView?.OnPointerEntered(e);
|
||||
}
|
||||
|
||||
|
||||
hitView?.OnPointerMoved(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[LinuxApplication] OnPointerPressed at ({e.X}, {e.Y})");
|
||||
|
||||
// Route to dialog if one is active
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.TopDialog?.OnPointerPressed(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_rootView != null)
|
||||
{
|
||||
var hitView = _rootView.HitTest(e.X, e.Y);
|
||||
// Check for popup overlay first
|
||||
var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
|
||||
var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
|
||||
Console.WriteLine($"[LinuxApplication] HitView: {hitView?.GetType().Name ?? "null"}, rootView: {_rootView.GetType().Name}");
|
||||
|
||||
if (hitView != null)
|
||||
{
|
||||
// Capture pointer to this view for drag operations
|
||||
_capturedView = hitView;
|
||||
|
||||
// Update focus
|
||||
if (hitView.IsFocusable)
|
||||
{
|
||||
FocusedView = hitView;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[LinuxApplication] Calling OnPointerPressed on {hitView.GetType().Name}");
|
||||
hitView.OnPointerPressed(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Close any open popups when clicking outside
|
||||
if (SkiaView.HasActivePopup && _focusedView != null)
|
||||
{
|
||||
_focusedView.OnFocusLost();
|
||||
}
|
||||
FocusedView = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -247,22 +436,42 @@ public class LinuxApplication : IDisposable
|
|||
|
||||
private void OnPointerReleased(object? sender, PointerEventArgs e)
|
||||
{
|
||||
// Route to dialog if one is active
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.TopDialog?.OnPointerReleased(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_rootView != null)
|
||||
{
|
||||
var hitView = _rootView.HitTest(e.X, e.Y);
|
||||
// If a view has captured the pointer, send release to it
|
||||
if (_capturedView != null)
|
||||
{
|
||||
_capturedView.OnPointerReleased(e);
|
||||
_capturedView = null; // Release capture
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for popup overlay first
|
||||
var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
|
||||
var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
|
||||
hitView?.OnPointerReleased(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScroll(object? sender, ScrollEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[LinuxApplication] OnScroll - X={e.X}, Y={e.Y}, DeltaX={e.DeltaX}, DeltaY={e.DeltaY}");
|
||||
if (_rootView != null)
|
||||
{
|
||||
var hitView = _rootView.HitTest(e.X, e.Y);
|
||||
Console.WriteLine($"[LinuxApplication] HitView: {hitView?.GetType().Name ?? "null"}");
|
||||
// Bubble scroll events up to find a ScrollView
|
||||
var view = hitView;
|
||||
while (view != null)
|
||||
{
|
||||
Console.WriteLine($"[LinuxApplication] Bubbling to: {view.GetType().Name}");
|
||||
if (view is SkiaScrollView scrollView)
|
||||
{
|
||||
scrollView.OnScroll(e);
|
||||
|
|
@ -324,6 +533,11 @@ public class LinuxApplicationOptions
|
|||
/// Gets or sets the display server type.
|
||||
/// </summary>
|
||||
public DisplayServerType DisplayServer { get; set; } = DisplayServerType.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to force demo mode instead of loading the application's pages.
|
||||
/// </summary>
|
||||
public bool ForceDemo { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -4,27 +4,28 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.Maui.Platform.Linux</RootNamespace>
|
||||
<AssemblyName>Microsoft.Maui.Controls.Linux</AssemblyName>
|
||||
<RootNamespace>OpenMaui.Platform.Linux</RootNamespace>
|
||||
<AssemblyName>OpenMaui.Controls.Linux</AssemblyName>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS0108;CS1591;CS0618</NoWarn>
|
||||
|
||||
<!-- NuGet Package Properties -->
|
||||
<PackageId>Microsoft.Maui.Controls.Linux</PackageId>
|
||||
<PackageId>OpenMaui.Controls.Linux</PackageId>
|
||||
<Version>1.0.0-preview.4</Version>
|
||||
<Authors>MAUI Linux Community Contributors</Authors>
|
||||
<Company>.NET Foundation</Company>
|
||||
<Product>.NET MAUI Linux Controls</Product>
|
||||
<Description>Linux desktop support for .NET MAUI applications using SkiaSharp rendering. Supports X11 and Wayland display servers.</Description>
|
||||
<Copyright>Copyright 2024-2025 .NET Foundation and Contributors</Copyright>
|
||||
<Authors>MarketAlly LLC, David H. Friedel Jr.</Authors>
|
||||
<Company>MarketAlly LLC</Company>
|
||||
<Product>OpenMaui Linux Controls</Product>
|
||||
<Description>Linux desktop support for .NET MAUI applications using SkiaSharp rendering. Supports X11 and Wayland display servers with 35+ controls, platform services, and accessibility support.</Description>
|
||||
<Copyright>Copyright 2025 MarketAlly LLC</Copyright>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/dotnet/maui</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/dotnet/maui.git</RepositoryUrl>
|
||||
<PackageProjectUrl>https://git.marketally.com/open-maui/maui-linux</PackageProjectUrl>
|
||||
<RepositoryUrl>https://git.marketally.com/open-maui/maui-linux.git</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>maui;linux;desktop;skia;gui;cross-platform;dotnet;x11;wayland</PackageTags>
|
||||
<PackageReleaseNotes>Preview 2 with Image, ImageButton, and GraphicsView controls.</PackageReleaseNotes>
|
||||
<PackageTags>maui;linux;desktop;skia;gui;cross-platform;dotnet;x11;wayland;openmaui</PackageTags>
|
||||
<PackageReleaseNotes>Preview 4: Fixed handler rendering for layouts, text wrapping, and scrollbar measurement issues.</PackageReleaseNotes>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
|
@ -35,27 +36,35 @@
|
|||
<PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.40" />
|
||||
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="9.0.40" />
|
||||
|
||||
<!-- SkiaSharp for rendering -->
|
||||
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="3.116.1" />
|
||||
<!-- SkiaSharp for rendering (2.88.x for FreeType compatibility) -->
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.9" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
|
||||
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="2.88.9" />
|
||||
|
||||
<!-- HarfBuzz for advanced text shaping -->
|
||||
<PackageReference Include="HarfBuzzSharp" Version="7.3.0.3" />
|
||||
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Include README in package -->
|
||||
<!-- Include README and icon in package -->
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="" />
|
||||
<None Include="assets/icon.png" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude old handler files and samples -->
|
||||
<!-- Exclude old handler files, samples, templates, and VSIX -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="Handlers/*.Linux.cs" />
|
||||
<Compile Remove="samples/**/*.cs" />
|
||||
<Compile Remove="tests/**/*.cs" />
|
||||
<Compile Remove="templates/**/*.cs" />
|
||||
<Compile Remove="vsix/**/*.cs" />
|
||||
<None Remove="vsix/**/*.xaml" />
|
||||
<None Remove="templates/**/*.xaml" />
|
||||
<None Remove="samples/**/*.xaml" />
|
||||
<MauiXaml Remove="vsix/**/*.xaml" />
|
||||
<MauiXaml Remove="templates/**/*.xaml" />
|
||||
<MauiXaml Remove="samples/**/*.xaml" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>Microsoft.Maui.Controls.Linux</id>
|
||||
<id>OpenMaui.Controls.Linux</id>
|
||||
<version>1.0.0-preview.1</version>
|
||||
<title>.NET MAUI Linux Controls</title>
|
||||
<title>OpenMaui Linux Controls</title>
|
||||
<authors>MarketAlly LLC, David H. Friedel Jr.</authors>
|
||||
<owners>MarketAlly LLC</owners>
|
||||
<license type="expression">MIT</license>
|
||||
|
|
@ -33,7 +33,7 @@ Initial release:
|
|||
- 216 unit tests
|
||||
</releaseNotes>
|
||||
<copyright>Copyright 2025 MarketAlly LLC</copyright>
|
||||
<tags>maui linux desktop skia gui cross-platform dotnet</tags>
|
||||
<tags>maui linux desktop skia gui cross-platform dotnet openmaui</tags>
|
||||
<repository type="git" url="https://github.com/open-maui/maui-linux.git" />
|
||||
<dependencies>
|
||||
<group targetFramework="net9.0">
|
||||
|
|
@ -47,8 +47,8 @@ Initial release:
|
|||
</frameworkAssemblies>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="bin/Release/net9.0/Microsoft.Maui.Controls.Linux.dll" target="lib/net9.0" />
|
||||
<file src="bin/Release/net9.0/Microsoft.Maui.Controls.Linux.xml" target="lib/net9.0" />
|
||||
<file src="bin/Release/net9.0/OpenMaui.Controls.Linux.dll" target="lib/net9.0" />
|
||||
<file src="bin/Release/net9.0/OpenMaui.Controls.Linux.xml" target="lib/net9.0" />
|
||||
<file src="README.md" target="" />
|
||||
</files>
|
||||
</package>
|
||||
65
README.md
65
README.md
|
|
@ -1,9 +1,8 @@
|
|||
# .NET MAUI Linux Platform
|
||||
# OpenMaui Linux Platform
|
||||
|
||||
A comprehensive Linux platform implementation for .NET MAUI using SkiaSharp rendering.
|
||||
|
||||
[](https://github.com/open-maui/maui-linux/actions)
|
||||
[](https://www.nuget.org/packages/Microsoft.Maui.Controls.Linux)
|
||||
[](https://www.nuget.org/packages/OpenMaui.Controls.Linux)
|
||||
[](LICENSE)
|
||||
|
||||
**Developed by [MarketAlly LLC](https://marketally.com)**
|
||||
|
|
@ -27,21 +26,47 @@ This project brings .NET MAUI to Linux desktops with native X11/Wayland support,
|
|||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the template
|
||||
dotnet new install Microsoft.Maui.Linux.Templates
|
||||
# Install the templates
|
||||
dotnet new install OpenMaui.Linux.Templates
|
||||
|
||||
# Create a new project (choose one):
|
||||
dotnet new openmaui-linux -n MyApp # Code-based UI
|
||||
dotnet new openmaui-linux-xaml -n MyApp # XAML-based UI (recommended)
|
||||
|
||||
# Create a new project
|
||||
dotnet new maui-linux -n MyApp
|
||||
cd MyApp
|
||||
|
||||
# Run
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Maui.Controls.Linux --prerelease
|
||||
dotnet add package OpenMaui.Controls.Linux --prerelease
|
||||
```
|
||||
|
||||
## XAML Support
|
||||
|
||||
OpenMaui fully supports standard .NET MAUI XAML syntax. Use the familiar XAML workflow:
|
||||
|
||||
```xml
|
||||
<!-- MainPage.xaml -->
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="MyApp.MainPage">
|
||||
<VerticalStackLayout>
|
||||
<Label Text="Hello, OpenMaui!" FontSize="32" />
|
||||
<Button Text="Click me" Clicked="OnButtonClicked" />
|
||||
<Entry Placeholder="Enter text..." />
|
||||
<Slider Minimum="0" Maximum="100" />
|
||||
</VerticalStackLayout>
|
||||
</ContentPage>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MauiProgram.cs
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder
|
||||
.UseMauiApp<App>()
|
||||
.UseOpenMauiLinux(); // Enable Linux with XAML support
|
||||
```
|
||||
|
||||
## Supported Controls
|
||||
|
|
@ -104,13 +129,23 @@ sudo dnf install libX11-devel libXrandr-devel libXcursor-devel libXi-devel mesa-
|
|||
## Documentation
|
||||
|
||||
- [Getting Started Guide](docs/GETTING_STARTED.md)
|
||||
- [FAQ - Visual Studio Integration](docs/FAQ.md)
|
||||
- [API Reference](docs/API.md)
|
||||
- [Contributing Guide](CONTRIBUTING.md)
|
||||
|
||||
## Sample Application
|
||||
## Sample Applications
|
||||
|
||||
Full sample applications are available in the [maui-linux-samples](https://git.marketally.com/open-maui/maui-linux-samples) repository:
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| **[TodoApp](https://git.marketally.com/open-maui/maui-linux-samples/src/branch/main/TodoApp)** | Task manager with NavigationPage, XAML data binding, CollectionView |
|
||||
| **[ShellDemo](https://git.marketally.com/open-maui/maui-linux-samples/src/branch/main/ShellDemo)** | Control showcase with Shell navigation and flyout menu |
|
||||
|
||||
## Quick Example
|
||||
|
||||
```csharp
|
||||
using Microsoft.Maui.Platform;
|
||||
using OpenMaui.Platform.Linux;
|
||||
|
||||
var app = new LinuxApplication();
|
||||
|
||||
|
|
@ -144,7 +179,7 @@ app.Run();
|
|||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-maui/maui-linux.git
|
||||
git clone https://git.marketally.com/open-maui/maui-linux.git
|
||||
cd maui-linux
|
||||
dotnet build
|
||||
dotnet test
|
||||
|
|
@ -198,3 +233,7 @@ Copyright (c) 2025 MarketAlly LLC. Licensed under the MIT License - see the [LIC
|
|||
- [SkiaSharp](https://github.com/mono/SkiaSharp) - 2D graphics library
|
||||
- [.NET MAUI](https://github.com/dotnet/maui) - Cross-platform UI framework
|
||||
- The .NET community
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux.Window;
|
||||
using Microsoft.Maui.Platform;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Rendering;
|
||||
|
|
@ -86,7 +87,13 @@ public class SkiaRenderingEngine : IDisposable
|
|||
|
||||
// Draw popup overlays (dropdowns, calendars, etc.) on top
|
||||
SkiaView.DrawPopupOverlays(_canvas);
|
||||
|
||||
|
||||
// Draw modal dialogs on top of everything
|
||||
if (LinuxDialogService.HasActiveDialog)
|
||||
{
|
||||
LinuxDialogService.DrawDialogs(_canvas, new SKRect(0, 0, Width, Height));
|
||||
}
|
||||
|
||||
_canvas.Flush();
|
||||
|
||||
// Present to X11 window
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux.Window;
|
||||
using Microsoft.Maui.Platform.Linux.Rendering;
|
||||
|
||||
|
|
@ -216,113 +215,25 @@ public class X11DisplayWindow : IDisplayWindow
|
|||
|
||||
/// <summary>
|
||||
/// Wayland display window wrapper implementing IDisplayWindow.
|
||||
/// Uses wl_shm for software rendering with SkiaSharp.
|
||||
/// Uses the full WaylandWindow implementation with xdg-shell protocol.
|
||||
/// </summary>
|
||||
public class WaylandDisplayWindow : IDisplayWindow
|
||||
{
|
||||
#region Native Interop
|
||||
private readonly WaylandWindow _window;
|
||||
|
||||
private const string LibWaylandClient = "libwayland-client.so.0";
|
||||
public int Width => _window.Width;
|
||||
public int Height => _window.Height;
|
||||
public bool IsRunning => _window.IsRunning;
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern IntPtr wl_display_connect(string? name);
|
||||
/// <summary>
|
||||
/// Gets the pixel data pointer for rendering.
|
||||
/// </summary>
|
||||
public IntPtr PixelData => _window.PixelData;
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_display_disconnect(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern int wl_display_dispatch(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern int wl_display_dispatch_pending(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern int wl_display_roundtrip(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern int wl_display_flush(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern IntPtr wl_display_get_registry(IntPtr display);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern IntPtr wl_compositor_create_surface(IntPtr compositor);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_surface_attach(IntPtr surface, IntPtr buffer, int x, int y);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_surface_damage(IntPtr surface, int x, int y, int width, int height);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_surface_commit(IntPtr surface);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_surface_destroy(IntPtr surface);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern IntPtr wl_shm_create_pool(IntPtr shm, int fd, int size);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_shm_pool_destroy(IntPtr pool);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern IntPtr wl_shm_pool_create_buffer(IntPtr pool, int offset, int width, int height, int stride, uint format);
|
||||
|
||||
[DllImport(LibWaylandClient)]
|
||||
private static extern void wl_buffer_destroy(IntPtr buffer);
|
||||
|
||||
[DllImport("libc", EntryPoint = "shm_open")]
|
||||
private static extern int shm_open([MarshalAs(UnmanagedType.LPStr)] string name, int oflag, int mode);
|
||||
|
||||
[DllImport("libc", EntryPoint = "shm_unlink")]
|
||||
private static extern int shm_unlink([MarshalAs(UnmanagedType.LPStr)] string name);
|
||||
|
||||
[DllImport("libc", EntryPoint = "ftruncate")]
|
||||
private static extern int ftruncate(int fd, long length);
|
||||
|
||||
[DllImport("libc", EntryPoint = "mmap")]
|
||||
private static extern IntPtr mmap(IntPtr addr, nuint length, int prot, int flags, int fd, long offset);
|
||||
|
||||
[DllImport("libc", EntryPoint = "munmap")]
|
||||
private static extern int munmap(IntPtr addr, nuint length);
|
||||
|
||||
[DllImport("libc", EntryPoint = "close")]
|
||||
private static extern int close(int fd);
|
||||
|
||||
private const int O_RDWR = 2;
|
||||
private const int O_CREAT = 0x40;
|
||||
private const int O_EXCL = 0x80;
|
||||
private const int PROT_READ = 1;
|
||||
private const int PROT_WRITE = 2;
|
||||
private const int MAP_SHARED = 1;
|
||||
private const uint WL_SHM_FORMAT_XRGB8888 = 1;
|
||||
|
||||
#endregion
|
||||
|
||||
private IntPtr _display;
|
||||
private IntPtr _registry;
|
||||
private IntPtr _compositor;
|
||||
private IntPtr _shm;
|
||||
private IntPtr _surface;
|
||||
private IntPtr _shmPool;
|
||||
private IntPtr _buffer;
|
||||
private IntPtr _pixelData;
|
||||
private int _shmFd = -1;
|
||||
private int _bufferSize;
|
||||
|
||||
private int _width;
|
||||
private int _height;
|
||||
private string _title;
|
||||
private bool _isRunning;
|
||||
private bool _disposed;
|
||||
|
||||
private SKBitmap? _bitmap;
|
||||
private SKCanvas? _canvas;
|
||||
|
||||
public int Width => _width;
|
||||
public int Height => _height;
|
||||
public bool IsRunning => _isRunning;
|
||||
/// <summary>
|
||||
/// Gets the stride (bytes per row) of the pixel buffer.
|
||||
/// </summary>
|
||||
public int Stride => _window.Stride;
|
||||
|
||||
public event EventHandler<KeyEventArgs>? KeyDown;
|
||||
public event EventHandler<KeyEventArgs>? KeyUp;
|
||||
|
|
@ -337,213 +248,27 @@ public class WaylandDisplayWindow : IDisplayWindow
|
|||
|
||||
public WaylandDisplayWindow(string title, int width, int height)
|
||||
{
|
||||
_title = title;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_window = new WaylandWindow(title, width, height);
|
||||
|
||||
Initialize();
|
||||
// Wire up events
|
||||
_window.KeyDown += (s, e) => KeyDown?.Invoke(this, e);
|
||||
_window.KeyUp += (s, e) => KeyUp?.Invoke(this, e);
|
||||
_window.TextInput += (s, e) => TextInput?.Invoke(this, e);
|
||||
_window.PointerMoved += (s, e) => PointerMoved?.Invoke(this, e);
|
||||
_window.PointerPressed += (s, e) => PointerPressed?.Invoke(this, e);
|
||||
_window.PointerReleased += (s, e) => PointerReleased?.Invoke(this, e);
|
||||
_window.Scroll += (s, e) => Scroll?.Invoke(this, e);
|
||||
_window.Exposed += (s, e) => Exposed?.Invoke(this, e);
|
||||
_window.Resized += (s, e) => Resized?.Invoke(this, e);
|
||||
_window.CloseRequested += (s, e) => CloseRequested?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
_display = wl_display_connect(null);
|
||||
if (_display == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to connect to Wayland display. Is WAYLAND_DISPLAY set?");
|
||||
}
|
||||
|
||||
_registry = wl_display_get_registry(_display);
|
||||
if (_registry == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to get Wayland registry");
|
||||
}
|
||||
|
||||
// Note: A full implementation would set up registry listeners to get
|
||||
// compositor and shm handles. For now, we throw an informative error
|
||||
// and fall back to X11 via XWayland in DisplayServerFactory.
|
||||
|
||||
// This is a placeholder - proper Wayland support requires:
|
||||
// 1. Setting up wl_registry_listener with callbacks
|
||||
// 2. Binding to wl_compositor, wl_shm, wl_seat, xdg_wm_base
|
||||
// 3. Implementing the xdg-shell protocol for toplevel windows
|
||||
|
||||
wl_display_roundtrip(_display);
|
||||
|
||||
// For now, signal that native Wayland isn't fully implemented
|
||||
throw new NotSupportedException(
|
||||
"Native Wayland support is experimental. " +
|
||||
"Set MAUI_PREFER_X11=1 to use XWayland, or run with DISPLAY set.");
|
||||
}
|
||||
|
||||
private void CreateShmBuffer()
|
||||
{
|
||||
int stride = _width * 4;
|
||||
_bufferSize = stride * _height;
|
||||
|
||||
string shmName = $"/maui-shm-{Environment.ProcessId}-{DateTime.Now.Ticks}";
|
||||
_shmFd = shm_open(shmName, O_RDWR | O_CREAT | O_EXCL, 0600);
|
||||
|
||||
if (_shmFd < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to create shared memory file");
|
||||
}
|
||||
|
||||
shm_unlink(shmName);
|
||||
|
||||
if (ftruncate(_shmFd, _bufferSize) < 0)
|
||||
{
|
||||
close(_shmFd);
|
||||
throw new InvalidOperationException("Failed to resize shared memory");
|
||||
}
|
||||
|
||||
_pixelData = mmap(IntPtr.Zero, (nuint)_bufferSize, PROT_READ | PROT_WRITE, MAP_SHARED, _shmFd, 0);
|
||||
if (_pixelData == IntPtr.Zero || _pixelData == new IntPtr(-1))
|
||||
{
|
||||
close(_shmFd);
|
||||
throw new InvalidOperationException("Failed to mmap shared memory");
|
||||
}
|
||||
|
||||
_shmPool = wl_shm_create_pool(_shm, _shmFd, _bufferSize);
|
||||
if (_shmPool == IntPtr.Zero)
|
||||
{
|
||||
munmap(_pixelData, (nuint)_bufferSize);
|
||||
close(_shmFd);
|
||||
throw new InvalidOperationException("Failed to create wl_shm_pool");
|
||||
}
|
||||
|
||||
_buffer = wl_shm_pool_create_buffer(_shmPool, 0, _width, _height, stride, WL_SHM_FORMAT_XRGB8888);
|
||||
if (_buffer == IntPtr.Zero)
|
||||
{
|
||||
wl_shm_pool_destroy(_shmPool);
|
||||
munmap(_pixelData, (nuint)_bufferSize);
|
||||
close(_shmFd);
|
||||
throw new InvalidOperationException("Failed to create wl_buffer");
|
||||
}
|
||||
|
||||
// Create Skia bitmap backed by shared memory
|
||||
var info = new SKImageInfo(_width, _height, SKColorType.Bgra8888, SKAlphaType.Opaque);
|
||||
_bitmap = new SKBitmap();
|
||||
_bitmap.InstallPixels(info, _pixelData, stride);
|
||||
_canvas = new SKCanvas(_bitmap);
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_surface == IntPtr.Zero || _buffer == IntPtr.Zero) return;
|
||||
|
||||
wl_surface_attach(_surface, _buffer, 0, 0);
|
||||
wl_surface_damage(_surface, 0, 0, _width, _height);
|
||||
wl_surface_commit(_surface);
|
||||
wl_display_flush(_display);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (_surface == IntPtr.Zero) return;
|
||||
|
||||
wl_surface_attach(_surface, IntPtr.Zero, 0, 0);
|
||||
wl_surface_commit(_surface);
|
||||
wl_display_flush(_display);
|
||||
}
|
||||
|
||||
public void SetTitle(string title)
|
||||
{
|
||||
_title = title;
|
||||
}
|
||||
|
||||
public void Resize(int width, int height)
|
||||
{
|
||||
if (width == _width && height == _height) return;
|
||||
|
||||
_canvas?.Dispose();
|
||||
_bitmap?.Dispose();
|
||||
|
||||
if (_buffer != IntPtr.Zero)
|
||||
wl_buffer_destroy(_buffer);
|
||||
if (_shmPool != IntPtr.Zero)
|
||||
wl_shm_pool_destroy(_shmPool);
|
||||
if (_pixelData != IntPtr.Zero)
|
||||
munmap(_pixelData, (nuint)_bufferSize);
|
||||
if (_shmFd >= 0)
|
||||
close(_shmFd);
|
||||
|
||||
_width = width;
|
||||
_height = height;
|
||||
|
||||
CreateShmBuffer();
|
||||
Resized?.Invoke(this, (width, height));
|
||||
}
|
||||
|
||||
public void ProcessEvents()
|
||||
{
|
||||
if (!_isRunning || _display == IntPtr.Zero) return;
|
||||
|
||||
wl_display_dispatch_pending(_display);
|
||||
wl_display_flush(_display);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
}
|
||||
|
||||
public SKCanvas? GetCanvas() => _canvas;
|
||||
|
||||
public void CommitFrame()
|
||||
{
|
||||
if (_surface != IntPtr.Zero && _buffer != IntPtr.Zero)
|
||||
{
|
||||
wl_surface_attach(_surface, _buffer, 0, 0);
|
||||
wl_surface_damage(_surface, 0, 0, _width, _height);
|
||||
wl_surface_commit(_surface);
|
||||
wl_display_flush(_display);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_isRunning = false;
|
||||
|
||||
_canvas?.Dispose();
|
||||
_bitmap?.Dispose();
|
||||
|
||||
if (_buffer != IntPtr.Zero)
|
||||
{
|
||||
wl_buffer_destroy(_buffer);
|
||||
_buffer = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (_shmPool != IntPtr.Zero)
|
||||
{
|
||||
wl_shm_pool_destroy(_shmPool);
|
||||
_shmPool = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (_pixelData != IntPtr.Zero && _pixelData != new IntPtr(-1))
|
||||
{
|
||||
munmap(_pixelData, (nuint)_bufferSize);
|
||||
_pixelData = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (_shmFd >= 0)
|
||||
{
|
||||
close(_shmFd);
|
||||
_shmFd = -1;
|
||||
}
|
||||
|
||||
if (_surface != IntPtr.Zero)
|
||||
{
|
||||
wl_surface_destroy(_surface);
|
||||
_surface = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (_display != IntPtr.Zero)
|
||||
{
|
||||
wl_display_disconnect(_display);
|
||||
_display = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
public void Show() => _window.Show();
|
||||
public void Hide() => _window.Hide();
|
||||
public void SetTitle(string title) => _window.SetTitle(title);
|
||||
public void Resize(int width, int height) => _window.Resize(width, height);
|
||||
public void ProcessEvents() => _window.ProcessEvents();
|
||||
public void Stop() => _window.Stop();
|
||||
public void CommitFrame() => _window.CommitFrame();
|
||||
public void Dispose() => _window.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,821 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
||||
|
||||
/// <summary>
|
||||
/// GTK4 dialog response codes.
|
||||
/// </summary>
|
||||
public enum GtkResponseType
|
||||
{
|
||||
None = -1,
|
||||
Reject = -2,
|
||||
Accept = -3,
|
||||
DeleteEvent = -4,
|
||||
Ok = -5,
|
||||
Cancel = -6,
|
||||
Close = -7,
|
||||
Yes = -8,
|
||||
No = -9,
|
||||
Apply = -10,
|
||||
Help = -11
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTK4 message dialog types.
|
||||
/// </summary>
|
||||
public enum GtkMessageType
|
||||
{
|
||||
Info = 0,
|
||||
Warning = 1,
|
||||
Question = 2,
|
||||
Error = 3,
|
||||
Other = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTK4 button layouts for dialogs.
|
||||
/// </summary>
|
||||
public enum GtkButtonsType
|
||||
{
|
||||
None = 0,
|
||||
Ok = 1,
|
||||
Close = 2,
|
||||
Cancel = 3,
|
||||
YesNo = 4,
|
||||
OkCancel = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTK4 file chooser actions.
|
||||
/// </summary>
|
||||
public enum GtkFileChooserAction
|
||||
{
|
||||
Open = 0,
|
||||
Save = 1,
|
||||
SelectFolder = 2,
|
||||
CreateFolder = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from a file dialog.
|
||||
/// </summary>
|
||||
public class FileDialogResult
|
||||
{
|
||||
public bool Accepted { get; init; }
|
||||
public string[] SelectedFiles { get; init; } = Array.Empty<string>();
|
||||
public string? SelectedFile => SelectedFiles.Length > 0 ? SelectedFiles[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from a color dialog.
|
||||
/// </summary>
|
||||
public class ColorDialogResult
|
||||
{
|
||||
public bool Accepted { get; init; }
|
||||
public float Red { get; init; }
|
||||
public float Green { get; init; }
|
||||
public float Blue { get; init; }
|
||||
public float Alpha { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GTK4 interop layer for native Linux dialogs.
|
||||
/// Provides native file pickers, message boxes, and color choosers.
|
||||
/// </summary>
|
||||
public class Gtk4InteropService : IDisposable
|
||||
{
|
||||
#region GTK4 Native Interop
|
||||
|
||||
private const string LibGtk4 = "libgtk-4.so.1";
|
||||
private const string LibGio = "libgio-2.0.so.0";
|
||||
private const string LibGlib = "libglib-2.0.so.0";
|
||||
private const string LibGObject = "libgobject-2.0.so.0";
|
||||
|
||||
// GTK initialization
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern bool gtk_init_check();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern bool gtk_is_initialized();
|
||||
|
||||
// Main loop
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr g_main_context_default();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern bool g_main_context_iteration(IntPtr context, bool mayBlock);
|
||||
|
||||
[DllImport(LibGlib)]
|
||||
private static extern void g_free(IntPtr mem);
|
||||
|
||||
// GObject
|
||||
[DllImport(LibGObject)]
|
||||
private static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
[DllImport(LibGObject)]
|
||||
private static extern void g_object_ref(IntPtr obj);
|
||||
|
||||
// Window
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_window_new();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_set_title(IntPtr window, [MarshalAs(UnmanagedType.LPStr)] string title);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_set_modal(IntPtr window, bool modal);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_set_transient_for(IntPtr window, IntPtr parent);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_destroy(IntPtr window);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_present(IntPtr window);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_window_close(IntPtr window);
|
||||
|
||||
// Widget
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_widget_show(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_widget_hide(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_widget_set_visible(IntPtr widget, bool visible);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern bool gtk_widget_get_visible(IntPtr widget);
|
||||
|
||||
// Alert Dialog (GTK4)
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_alert_dialog_new([MarshalAs(UnmanagedType.LPStr)] string format);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_set_message(IntPtr dialog, [MarshalAs(UnmanagedType.LPStr)] string message);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_set_detail(IntPtr dialog, [MarshalAs(UnmanagedType.LPStr)] string detail);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_set_buttons(IntPtr dialog, string[] labels);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_set_cancel_button(IntPtr dialog, int button);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_set_default_button(IntPtr dialog, int button);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_alert_dialog_show(IntPtr dialog, IntPtr parent);
|
||||
|
||||
// File Dialog (GTK4)
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_dialog_new();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_set_title(IntPtr dialog, [MarshalAs(UnmanagedType.LPStr)] string title);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_set_modal(IntPtr dialog, bool modal);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_set_accept_label(IntPtr dialog, [MarshalAs(UnmanagedType.LPStr)] string label);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_open(IntPtr dialog, IntPtr parent, IntPtr cancellable, GAsyncReadyCallback callback, IntPtr userData);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_dialog_open_finish(IntPtr dialog, IntPtr result, out IntPtr error);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_save(IntPtr dialog, IntPtr parent, IntPtr cancellable, GAsyncReadyCallback callback, IntPtr userData);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_dialog_save_finish(IntPtr dialog, IntPtr result, out IntPtr error);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_select_folder(IntPtr dialog, IntPtr parent, IntPtr cancellable, GAsyncReadyCallback callback, IntPtr userData);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_dialog_select_folder_finish(IntPtr dialog, IntPtr result, out IntPtr error);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_open_multiple(IntPtr dialog, IntPtr parent, IntPtr cancellable, GAsyncReadyCallback callback, IntPtr userData);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_dialog_open_multiple_finish(IntPtr dialog, IntPtr result, out IntPtr error);
|
||||
|
||||
// File filters
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_file_filter_new();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_filter_set_name(IntPtr filter, [MarshalAs(UnmanagedType.LPStr)] string name);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_filter_add_pattern(IntPtr filter, [MarshalAs(UnmanagedType.LPStr)] string pattern);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_filter_add_mime_type(IntPtr filter, [MarshalAs(UnmanagedType.LPStr)] string mimeType);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_file_dialog_set_default_filter(IntPtr dialog, IntPtr filter);
|
||||
|
||||
// GFile
|
||||
[DllImport(LibGio)]
|
||||
private static extern IntPtr g_file_get_path(IntPtr file);
|
||||
|
||||
// GListModel for multiple files
|
||||
[DllImport(LibGio)]
|
||||
private static extern uint g_list_model_get_n_items(IntPtr list);
|
||||
|
||||
[DllImport(LibGio)]
|
||||
private static extern IntPtr g_list_model_get_item(IntPtr list, uint position);
|
||||
|
||||
// Color Dialog (GTK4)
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_color_dialog_new();
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_color_dialog_set_title(IntPtr dialog, [MarshalAs(UnmanagedType.LPStr)] string title);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_color_dialog_set_modal(IntPtr dialog, bool modal);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_color_dialog_set_with_alpha(IntPtr dialog, bool withAlpha);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern void gtk_color_dialog_choose_rgba(IntPtr dialog, IntPtr parent, IntPtr initialColor, IntPtr cancellable, GAsyncReadyCallback callback, IntPtr userData);
|
||||
|
||||
[DllImport(LibGtk4)]
|
||||
private static extern IntPtr gtk_color_dialog_choose_rgba_finish(IntPtr dialog, IntPtr result, out IntPtr error);
|
||||
|
||||
// GdkRGBA
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct GdkRGBA
|
||||
{
|
||||
public float Red;
|
||||
public float Green;
|
||||
public float Blue;
|
||||
public float Alpha;
|
||||
}
|
||||
|
||||
// Async callback delegate
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void GAsyncReadyCallback(IntPtr sourceObject, IntPtr result, IntPtr userData);
|
||||
|
||||
// Legacy GTK3 fallbacks
|
||||
private const string LibGtk3 = "libgtk-3.so.0";
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_init_check")]
|
||||
private static extern bool gtk3_init_check(ref int argc, ref IntPtr argv);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_file_chooser_dialog_new")]
|
||||
private static extern IntPtr gtk3_file_chooser_dialog_new(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string title,
|
||||
IntPtr parent,
|
||||
int action,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string firstButtonText,
|
||||
int firstButtonResponse,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string secondButtonText,
|
||||
int secondButtonResponse,
|
||||
IntPtr terminator);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_dialog_run")]
|
||||
private static extern int gtk3_dialog_run(IntPtr dialog);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_widget_destroy")]
|
||||
private static extern void gtk3_widget_destroy(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_file_chooser_get_filename")]
|
||||
private static extern IntPtr gtk3_file_chooser_get_filename(IntPtr chooser);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_file_chooser_get_filenames")]
|
||||
private static extern IntPtr gtk3_file_chooser_get_filenames(IntPtr chooser);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_file_chooser_set_select_multiple")]
|
||||
private static extern void gtk3_file_chooser_set_select_multiple(IntPtr chooser, bool selectMultiple);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_message_dialog_new")]
|
||||
private static extern IntPtr gtk3_message_dialog_new(
|
||||
IntPtr parent,
|
||||
int flags,
|
||||
int type,
|
||||
int buttons,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string message);
|
||||
|
||||
[DllImport(LibGlib, EntryPoint = "g_slist_length")]
|
||||
private static extern uint g_slist_length(IntPtr list);
|
||||
|
||||
[DllImport(LibGlib, EntryPoint = "g_slist_nth_data")]
|
||||
private static extern IntPtr g_slist_nth_data(IntPtr list, uint n);
|
||||
|
||||
[DllImport(LibGlib, EntryPoint = "g_slist_free")]
|
||||
private static extern void g_slist_free(IntPtr list);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private bool _initialized;
|
||||
private bool _useGtk4;
|
||||
private bool _disposed;
|
||||
private readonly object _lock = new();
|
||||
|
||||
// Store callbacks to prevent GC
|
||||
private GAsyncReadyCallback? _currentCallback;
|
||||
private TaskCompletionSource<FileDialogResult>? _fileDialogTcs;
|
||||
private TaskCompletionSource<ColorDialogResult>? _colorDialogTcs;
|
||||
private IntPtr _currentDialog;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether GTK is initialized.
|
||||
/// </summary>
|
||||
public bool IsInitialized => _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether GTK4 is being used (vs GTK3 fallback).
|
||||
/// </summary>
|
||||
public bool IsGtk4 => _useGtk4;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the GTK4 interop service.
|
||||
/// Falls back to GTK3 if GTK4 is not available.
|
||||
/// </summary>
|
||||
public bool Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
return true;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_initialized)
|
||||
return true;
|
||||
|
||||
// Try GTK4 first
|
||||
try
|
||||
{
|
||||
if (gtk_init_check())
|
||||
{
|
||||
_useGtk4 = true;
|
||||
_initialized = true;
|
||||
Console.WriteLine("[GTK4] Initialized GTK4");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
Console.WriteLine("[GTK4] GTK4 not found, trying GTK3");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[GTK4] GTK4 init failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Fall back to GTK3
|
||||
try
|
||||
{
|
||||
int argc = 0;
|
||||
IntPtr argv = IntPtr.Zero;
|
||||
if (gtk3_init_check(ref argc, ref argv))
|
||||
{
|
||||
_useGtk4 = false;
|
||||
_initialized = true;
|
||||
Console.WriteLine("[GTK4] Initialized GTK3 (fallback)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
Console.WriteLine("[GTK4] GTK3 not found");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[GTK4] GTK3 init failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Message Dialogs
|
||||
|
||||
/// <summary>
|
||||
/// Shows an alert message dialog.
|
||||
/// </summary>
|
||||
public void ShowAlert(string title, string message, GtkMessageType type = GtkMessageType.Info)
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
var dialog = gtk_alert_dialog_new(title);
|
||||
gtk_alert_dialog_set_detail(dialog, message);
|
||||
string[] buttons = { "OK" };
|
||||
gtk_alert_dialog_set_buttons(dialog, buttons);
|
||||
gtk_alert_dialog_show(dialog, IntPtr.Zero);
|
||||
g_object_unref(dialog);
|
||||
}
|
||||
else
|
||||
{
|
||||
var dialog = gtk3_message_dialog_new(
|
||||
IntPtr.Zero,
|
||||
1, // GTK_DIALOG_MODAL
|
||||
(int)type,
|
||||
(int)GtkButtonsType.Ok,
|
||||
message);
|
||||
|
||||
gtk3_dialog_run(dialog);
|
||||
gtk3_widget_destroy(dialog);
|
||||
}
|
||||
|
||||
ProcessPendingEvents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a confirmation dialog.
|
||||
/// </summary>
|
||||
public bool ShowConfirmation(string title, string message)
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return false;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
// GTK4 async dialogs are more complex - use synchronous approach
|
||||
var dialog = gtk_alert_dialog_new(title);
|
||||
gtk_alert_dialog_set_detail(dialog, message);
|
||||
string[] buttons = { "No", "Yes" };
|
||||
gtk_alert_dialog_set_buttons(dialog, buttons);
|
||||
gtk_alert_dialog_set_default_button(dialog, 1);
|
||||
gtk_alert_dialog_set_cancel_button(dialog, 0);
|
||||
gtk_alert_dialog_show(dialog, IntPtr.Zero);
|
||||
g_object_unref(dialog);
|
||||
// Note: GTK4 alert dialogs are async, this is simplified
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var dialog = gtk3_message_dialog_new(
|
||||
IntPtr.Zero,
|
||||
1, // GTK_DIALOG_MODAL
|
||||
(int)GtkMessageType.Question,
|
||||
(int)GtkButtonsType.YesNo,
|
||||
message);
|
||||
|
||||
int response = gtk3_dialog_run(dialog);
|
||||
gtk3_widget_destroy(dialog);
|
||||
ProcessPendingEvents();
|
||||
|
||||
return response == (int)GtkResponseType.Yes;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region File Dialogs
|
||||
|
||||
/// <summary>
|
||||
/// Shows an open file dialog.
|
||||
/// </summary>
|
||||
public FileDialogResult ShowOpenFileDialog(
|
||||
string title = "Open File",
|
||||
string? initialFolder = null,
|
||||
bool allowMultiple = false,
|
||||
params (string Name, string Pattern)[] filters)
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return new FileDialogResult { Accepted = false };
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
return ShowGtk4FileDialog(title, GtkFileChooserAction.Open, allowMultiple, filters);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ShowGtk3FileDialog(title, 0, allowMultiple, filters); // GTK_FILE_CHOOSER_ACTION_OPEN = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a save file dialog.
|
||||
/// </summary>
|
||||
public FileDialogResult ShowSaveFileDialog(
|
||||
string title = "Save File",
|
||||
string? suggestedName = null,
|
||||
params (string Name, string Pattern)[] filters)
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return new FileDialogResult { Accepted = false };
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
return ShowGtk4FileDialog(title, GtkFileChooserAction.Save, false, filters);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ShowGtk3FileDialog(title, 1, false, filters); // GTK_FILE_CHOOSER_ACTION_SAVE = 1
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a folder picker dialog.
|
||||
/// </summary>
|
||||
public FileDialogResult ShowFolderDialog(string title = "Select Folder")
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return new FileDialogResult { Accepted = false };
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
return ShowGtk4FileDialog(title, GtkFileChooserAction.SelectFolder, false, Array.Empty<(string, string)>());
|
||||
}
|
||||
else
|
||||
{
|
||||
return ShowGtk3FileDialog(title, 2, false, Array.Empty<(string, string)>()); // GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER = 2
|
||||
}
|
||||
}
|
||||
|
||||
private FileDialogResult ShowGtk4FileDialog(
|
||||
string title,
|
||||
GtkFileChooserAction action,
|
||||
bool allowMultiple,
|
||||
(string Name, string Pattern)[] filters)
|
||||
{
|
||||
var dialog = gtk_file_dialog_new();
|
||||
gtk_file_dialog_set_title(dialog, title);
|
||||
gtk_file_dialog_set_modal(dialog, true);
|
||||
|
||||
// Set up filters
|
||||
if (filters.Length > 0)
|
||||
{
|
||||
var filter = gtk_file_filter_new();
|
||||
gtk_file_filter_set_name(filter, filters[0].Name);
|
||||
gtk_file_filter_add_pattern(filter, filters[0].Pattern);
|
||||
gtk_file_dialog_set_default_filter(dialog, filter);
|
||||
}
|
||||
|
||||
// For GTK4, we need async handling - simplified synchronous version
|
||||
// In a full implementation, this would use proper async/await
|
||||
_fileDialogTcs = new TaskCompletionSource<FileDialogResult>();
|
||||
_currentDialog = dialog;
|
||||
|
||||
_currentCallback = (source, result, userData) =>
|
||||
{
|
||||
IntPtr error = IntPtr.Zero;
|
||||
IntPtr file = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
if (action == GtkFileChooserAction.Open && !allowMultiple)
|
||||
file = gtk_file_dialog_open_finish(dialog, result, out error);
|
||||
else if (action == GtkFileChooserAction.Save)
|
||||
file = gtk_file_dialog_save_finish(dialog, result, out error);
|
||||
else if (action == GtkFileChooserAction.SelectFolder)
|
||||
file = gtk_file_dialog_select_folder_finish(dialog, result, out error);
|
||||
|
||||
if (file != IntPtr.Zero && error == IntPtr.Zero)
|
||||
{
|
||||
IntPtr pathPtr = g_file_get_path(file);
|
||||
string path = Marshal.PtrToStringUTF8(pathPtr) ?? "";
|
||||
g_free(pathPtr);
|
||||
g_object_unref(file);
|
||||
|
||||
_fileDialogTcs?.TrySetResult(new FileDialogResult
|
||||
{
|
||||
Accepted = true,
|
||||
SelectedFiles = new[] { path }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_fileDialogTcs?.TrySetResult(new FileDialogResult { Accepted = false });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_fileDialogTcs?.TrySetResult(new FileDialogResult { Accepted = false });
|
||||
}
|
||||
};
|
||||
|
||||
// Start the dialog
|
||||
if (action == GtkFileChooserAction.Open && !allowMultiple)
|
||||
gtk_file_dialog_open(dialog, IntPtr.Zero, IntPtr.Zero, _currentCallback, IntPtr.Zero);
|
||||
else if (action == GtkFileChooserAction.Open && allowMultiple)
|
||||
gtk_file_dialog_open_multiple(dialog, IntPtr.Zero, IntPtr.Zero, _currentCallback, IntPtr.Zero);
|
||||
else if (action == GtkFileChooserAction.Save)
|
||||
gtk_file_dialog_save(dialog, IntPtr.Zero, IntPtr.Zero, _currentCallback, IntPtr.Zero);
|
||||
else if (action == GtkFileChooserAction.SelectFolder)
|
||||
gtk_file_dialog_select_folder(dialog, IntPtr.Zero, IntPtr.Zero, _currentCallback, IntPtr.Zero);
|
||||
|
||||
// Process events until dialog completes
|
||||
while (!_fileDialogTcs.Task.IsCompleted)
|
||||
{
|
||||
ProcessPendingEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
g_object_unref(dialog);
|
||||
return _fileDialogTcs.Task.Result;
|
||||
}
|
||||
|
||||
private FileDialogResult ShowGtk3FileDialog(
|
||||
string title,
|
||||
int action,
|
||||
bool allowMultiple,
|
||||
(string Name, string Pattern)[] filters)
|
||||
{
|
||||
var dialog = gtk3_file_chooser_dialog_new(
|
||||
title,
|
||||
IntPtr.Zero,
|
||||
action,
|
||||
"_Cancel", (int)GtkResponseType.Cancel,
|
||||
action == 1 ? "_Save" : "_Open", (int)GtkResponseType.Accept,
|
||||
IntPtr.Zero);
|
||||
|
||||
if (allowMultiple)
|
||||
gtk3_file_chooser_set_select_multiple(dialog, true);
|
||||
|
||||
int response = gtk3_dialog_run(dialog);
|
||||
|
||||
var result = new FileDialogResult { Accepted = false };
|
||||
|
||||
if (response == (int)GtkResponseType.Accept)
|
||||
{
|
||||
if (allowMultiple)
|
||||
{
|
||||
IntPtr list = gtk3_file_chooser_get_filenames(dialog);
|
||||
uint count = g_slist_length(list);
|
||||
var files = new List<string>();
|
||||
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
IntPtr pathPtr = g_slist_nth_data(list, i);
|
||||
string? path = Marshal.PtrToStringUTF8(pathPtr);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
files.Add(path);
|
||||
g_free(pathPtr);
|
||||
}
|
||||
}
|
||||
|
||||
g_slist_free(list);
|
||||
result = new FileDialogResult { Accepted = true, SelectedFiles = files.ToArray() };
|
||||
}
|
||||
else
|
||||
{
|
||||
IntPtr pathPtr = gtk3_file_chooser_get_filename(dialog);
|
||||
string? path = Marshal.PtrToStringUTF8(pathPtr);
|
||||
g_free(pathPtr);
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
result = new FileDialogResult { Accepted = true, SelectedFiles = new[] { path } };
|
||||
}
|
||||
}
|
||||
|
||||
gtk3_widget_destroy(dialog);
|
||||
ProcessPendingEvents();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Color Dialog
|
||||
|
||||
/// <summary>
|
||||
/// Shows a color picker dialog.
|
||||
/// </summary>
|
||||
public ColorDialogResult ShowColorDialog(
|
||||
string title = "Choose Color",
|
||||
float initialRed = 1f,
|
||||
float initialGreen = 1f,
|
||||
float initialBlue = 1f,
|
||||
float initialAlpha = 1f,
|
||||
bool withAlpha = true)
|
||||
{
|
||||
if (!EnsureInitialized())
|
||||
return new ColorDialogResult { Accepted = false };
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
return ShowGtk4ColorDialog(title, initialRed, initialGreen, initialBlue, initialAlpha, withAlpha);
|
||||
}
|
||||
else
|
||||
{
|
||||
// GTK3 color dialog would go here
|
||||
return new ColorDialogResult { Accepted = false };
|
||||
}
|
||||
}
|
||||
|
||||
private ColorDialogResult ShowGtk4ColorDialog(
|
||||
string title,
|
||||
float r, float g, float b, float a,
|
||||
bool withAlpha)
|
||||
{
|
||||
var dialog = gtk_color_dialog_new();
|
||||
gtk_color_dialog_set_title(dialog, title);
|
||||
gtk_color_dialog_set_modal(dialog, true);
|
||||
gtk_color_dialog_set_with_alpha(dialog, withAlpha);
|
||||
|
||||
_colorDialogTcs = new TaskCompletionSource<ColorDialogResult>();
|
||||
|
||||
_currentCallback = (source, result, userData) =>
|
||||
{
|
||||
IntPtr error = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
IntPtr rgbaPtr = gtk_color_dialog_choose_rgba_finish(dialog, result, out error);
|
||||
if (rgbaPtr != IntPtr.Zero && error == IntPtr.Zero)
|
||||
{
|
||||
var rgba = Marshal.PtrToStructure<GdkRGBA>(rgbaPtr);
|
||||
_colorDialogTcs?.TrySetResult(new ColorDialogResult
|
||||
{
|
||||
Accepted = true,
|
||||
Red = rgba.Red,
|
||||
Green = rgba.Green,
|
||||
Blue = rgba.Blue,
|
||||
Alpha = rgba.Alpha
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_colorDialogTcs?.TrySetResult(new ColorDialogResult { Accepted = false });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_colorDialogTcs?.TrySetResult(new ColorDialogResult { Accepted = false });
|
||||
}
|
||||
};
|
||||
|
||||
gtk_color_dialog_choose_rgba(dialog, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, _currentCallback, IntPtr.Zero);
|
||||
|
||||
while (!_colorDialogTcs.Task.IsCompleted)
|
||||
{
|
||||
ProcessPendingEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
g_object_unref(dialog);
|
||||
return _colorDialogTcs.Task.Result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private bool EnsureInitialized()
|
||||
{
|
||||
if (!_initialized)
|
||||
Initialize();
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
private void ProcessPendingEvents()
|
||||
{
|
||||
var context = g_main_context_default();
|
||||
while (g_main_context_iteration(context, false)) { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_initialized = false;
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~Gtk4InteropService()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,722 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Supported hardware video acceleration APIs.
|
||||
/// </summary>
|
||||
public enum VideoAccelerationApi
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically select the best available API.
|
||||
/// </summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>
|
||||
/// VA-API (Video Acceleration API) - Intel, AMD, and some NVIDIA.
|
||||
/// </summary>
|
||||
VaApi,
|
||||
|
||||
/// <summary>
|
||||
/// VDPAU (Video Decode and Presentation API for Unix) - NVIDIA.
|
||||
/// </summary>
|
||||
Vdpau,
|
||||
|
||||
/// <summary>
|
||||
/// Software decoding fallback.
|
||||
/// </summary>
|
||||
Software
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Video codec profiles supported by hardware acceleration.
|
||||
/// </summary>
|
||||
public enum VideoProfile
|
||||
{
|
||||
H264Baseline,
|
||||
H264Main,
|
||||
H264High,
|
||||
H265Main,
|
||||
H265Main10,
|
||||
Vp8,
|
||||
Vp9Profile0,
|
||||
Vp9Profile2,
|
||||
Av1Main
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a decoded video frame.
|
||||
/// </summary>
|
||||
public class VideoFrame : IDisposable
|
||||
{
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
public IntPtr DataY { get; init; }
|
||||
public IntPtr DataU { get; init; }
|
||||
public IntPtr DataV { get; init; }
|
||||
public int StrideY { get; init; }
|
||||
public int StrideU { get; init; }
|
||||
public int StrideV { get; init; }
|
||||
public long Timestamp { get; init; }
|
||||
public bool IsKeyFrame { get; init; }
|
||||
|
||||
private bool _disposed;
|
||||
private Action? _releaseCallback;
|
||||
|
||||
internal void SetReleaseCallback(Action callback) => _releaseCallback = callback;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_releaseCallback?.Invoke();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hardware-accelerated video decoding service using VA-API or VDPAU.
|
||||
/// Provides efficient video decode for media playback on Linux.
|
||||
/// </summary>
|
||||
public class HardwareVideoService : IDisposable
|
||||
{
|
||||
#region VA-API Native Interop
|
||||
|
||||
private const string LibVa = "libva.so.2";
|
||||
private const string LibVaDrm = "libva-drm.so.2";
|
||||
private const string LibVaX11 = "libva-x11.so.2";
|
||||
|
||||
// VA-API error codes
|
||||
private const int VA_STATUS_SUCCESS = 0;
|
||||
|
||||
// VA-API profile constants
|
||||
private const int VAProfileH264Baseline = 5;
|
||||
private const int VAProfileH264Main = 6;
|
||||
private const int VAProfileH264High = 7;
|
||||
private const int VAProfileHEVCMain = 12;
|
||||
private const int VAProfileHEVCMain10 = 13;
|
||||
private const int VAProfileVP8Version0_3 = 14;
|
||||
private const int VAProfileVP9Profile0 = 15;
|
||||
private const int VAProfileVP9Profile2 = 17;
|
||||
private const int VAProfileAV1Profile0 = 20;
|
||||
|
||||
// VA-API entrypoint
|
||||
private const int VAEntrypointVLD = 1; // Video Decode
|
||||
|
||||
// Surface formats
|
||||
private const uint VA_RT_FORMAT_YUV420 = 0x00000001;
|
||||
private const uint VA_RT_FORMAT_YUV420_10 = 0x00000100;
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern IntPtr vaGetDisplayDRM(int fd);
|
||||
|
||||
[DllImport(LibVaX11)]
|
||||
private static extern IntPtr vaGetDisplay(IntPtr x11Display);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaInitialize(IntPtr display, out int majorVersion, out int minorVersion);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaTerminate(IntPtr display);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern IntPtr vaErrorStr(int errorCode);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaQueryConfigProfiles(IntPtr display, [Out] int[] profileList, out int numProfiles);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaQueryConfigEntrypoints(IntPtr display, int profile, [Out] int[] entrypoints, out int numEntrypoints);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaCreateConfig(IntPtr display, int profile, int entrypoint, IntPtr attribList, int numAttribs, out uint configId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaDestroyConfig(IntPtr display, uint configId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaCreateContext(IntPtr display, uint configId, int pictureWidth, int pictureHeight, int flag, IntPtr renderTargets, int numRenderTargets, out uint contextId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaDestroyContext(IntPtr display, uint contextId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaCreateSurfaces(IntPtr display, uint format, uint width, uint height, [Out] uint[] surfaces, uint numSurfaces, IntPtr attribList, uint numAttribs);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaDestroySurfaces(IntPtr display, [In] uint[] surfaces, int numSurfaces);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaSyncSurface(IntPtr display, uint surfaceId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaMapBuffer(IntPtr display, uint bufferId, out IntPtr data);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaUnmapBuffer(IntPtr display, uint bufferId);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaDeriveImage(IntPtr display, uint surfaceId, out VaImage image);
|
||||
|
||||
[DllImport(LibVa)]
|
||||
private static extern int vaDestroyImage(IntPtr display, uint imageId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct VaImage
|
||||
{
|
||||
public uint ImageId;
|
||||
public uint Format; // VAImageFormat (simplified)
|
||||
public uint FormatFourCC;
|
||||
public int Width;
|
||||
public int Height;
|
||||
public uint DataSize;
|
||||
public uint NumPlanes;
|
||||
public uint PitchesPlane0;
|
||||
public uint PitchesPlane1;
|
||||
public uint PitchesPlane2;
|
||||
public uint PitchesPlane3;
|
||||
public uint OffsetsPlane0;
|
||||
public uint OffsetsPlane1;
|
||||
public uint OffsetsPlane2;
|
||||
public uint OffsetsPlane3;
|
||||
public uint BufferId;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VDPAU Native Interop
|
||||
|
||||
private const string LibVdpau = "libvdpau.so.1";
|
||||
|
||||
[DllImport(LibVdpau)]
|
||||
private static extern int vdp_device_create_x11(IntPtr display, int screen, out IntPtr device, out IntPtr getProcAddress);
|
||||
|
||||
#endregion
|
||||
|
||||
#region DRM Interop
|
||||
|
||||
[DllImport("libc", EntryPoint = "open")]
|
||||
private static extern int open([MarshalAs(UnmanagedType.LPStr)] string path, int flags);
|
||||
|
||||
[DllImport("libc", EntryPoint = "close")]
|
||||
private static extern int close(int fd);
|
||||
|
||||
private const int O_RDWR = 2;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private IntPtr _vaDisplay;
|
||||
private uint _vaConfigId;
|
||||
private uint _vaContextId;
|
||||
private uint[] _vaSurfaces = Array.Empty<uint>();
|
||||
private int _drmFd = -1;
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
private VideoAccelerationApi _currentApi = VideoAccelerationApi.Software;
|
||||
private int _width;
|
||||
private int _height;
|
||||
private VideoProfile _profile;
|
||||
|
||||
private readonly HashSet<VideoProfile> _supportedProfiles = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active video acceleration API.
|
||||
/// </summary>
|
||||
public VideoAccelerationApi CurrentApi => _currentApi;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether hardware acceleration is available and initialized.
|
||||
/// </summary>
|
||||
public bool IsHardwareAccelerated => _currentApi != VideoAccelerationApi.Software && _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supported video profiles.
|
||||
/// </summary>
|
||||
public IReadOnlySet<VideoProfile> SupportedProfiles => _supportedProfiles;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new hardware video service.
|
||||
/// </summary>
|
||||
public HardwareVideoService()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the hardware video acceleration.
|
||||
/// </summary>
|
||||
/// <param name="api">The preferred API to use.</param>
|
||||
/// <param name="x11Display">Optional X11 display for VA-API X11 backend.</param>
|
||||
/// <returns>True if initialization succeeded.</returns>
|
||||
public bool Initialize(VideoAccelerationApi api = VideoAccelerationApi.Auto, IntPtr x11Display = default)
|
||||
{
|
||||
if (_initialized)
|
||||
return true;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_initialized)
|
||||
return true;
|
||||
|
||||
// Try VA-API first (works with Intel, AMD, and some NVIDIA)
|
||||
if (api == VideoAccelerationApi.Auto || api == VideoAccelerationApi.VaApi)
|
||||
{
|
||||
if (TryInitializeVaApi(x11Display))
|
||||
{
|
||||
_currentApi = VideoAccelerationApi.VaApi;
|
||||
_initialized = true;
|
||||
Console.WriteLine($"[HardwareVideo] Initialized VA-API with {_supportedProfiles.Count} supported profiles");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try VDPAU (NVIDIA proprietary)
|
||||
if (api == VideoAccelerationApi.Auto || api == VideoAccelerationApi.Vdpau)
|
||||
{
|
||||
if (TryInitializeVdpau(x11Display))
|
||||
{
|
||||
_currentApi = VideoAccelerationApi.Vdpau;
|
||||
_initialized = true;
|
||||
Console.WriteLine("[HardwareVideo] Initialized VDPAU");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("[HardwareVideo] No hardware acceleration available, using software");
|
||||
_currentApi = VideoAccelerationApi.Software;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryInitializeVaApi(IntPtr x11Display)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try DRM backend first (works in Wayland and headless)
|
||||
string[] drmDevices = { "/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0" };
|
||||
foreach (var device in drmDevices)
|
||||
{
|
||||
_drmFd = open(device, O_RDWR);
|
||||
if (_drmFd >= 0)
|
||||
{
|
||||
_vaDisplay = vaGetDisplayDRM(_drmFd);
|
||||
if (_vaDisplay != IntPtr.Zero)
|
||||
{
|
||||
if (InitializeVaDisplay())
|
||||
return true;
|
||||
}
|
||||
close(_drmFd);
|
||||
_drmFd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to X11 backend if display provided
|
||||
if (x11Display != IntPtr.Zero)
|
||||
{
|
||||
_vaDisplay = vaGetDisplay(x11Display);
|
||||
if (_vaDisplay != IntPtr.Zero && InitializeVaDisplay())
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
Console.WriteLine("[HardwareVideo] VA-API libraries not found");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] VA-API initialization failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool InitializeVaDisplay()
|
||||
{
|
||||
int status = vaInitialize(_vaDisplay, out int major, out int minor);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] vaInitialize failed: {GetVaError(status)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[HardwareVideo] VA-API {major}.{minor} initialized");
|
||||
|
||||
// Query supported profiles
|
||||
int[] profiles = new int[32];
|
||||
status = vaQueryConfigProfiles(_vaDisplay, profiles, out int numProfiles);
|
||||
if (status == VA_STATUS_SUCCESS)
|
||||
{
|
||||
for (int i = 0; i < numProfiles; i++)
|
||||
{
|
||||
if (TryMapVaProfile(profiles[i], out var videoProfile))
|
||||
{
|
||||
// Check if VLD (decode) entrypoint is supported
|
||||
int[] entrypoints = new int[8];
|
||||
if (vaQueryConfigEntrypoints(_vaDisplay, profiles[i], entrypoints, out int numEntrypoints) == VA_STATUS_SUCCESS)
|
||||
{
|
||||
for (int j = 0; j < numEntrypoints; j++)
|
||||
{
|
||||
if (entrypoints[j] == VAEntrypointVLD)
|
||||
{
|
||||
_supportedProfiles.Add(videoProfile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryInitializeVdpau(IntPtr x11Display)
|
||||
{
|
||||
if (x11Display == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
int result = vdp_device_create_x11(x11Display, 0, out IntPtr device, out IntPtr getProcAddress);
|
||||
if (result == 0 && device != IntPtr.Zero)
|
||||
{
|
||||
// VDPAU initialized - would need additional setup for actual use
|
||||
// For now, just mark as available
|
||||
_supportedProfiles.Add(VideoProfile.H264Baseline);
|
||||
_supportedProfiles.Add(VideoProfile.H264Main);
|
||||
_supportedProfiles.Add(VideoProfile.H264High);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
Console.WriteLine("[HardwareVideo] VDPAU libraries not found");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] VDPAU initialization failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decoder Creation
|
||||
|
||||
/// <summary>
|
||||
/// Creates a decoder context for the specified profile and dimensions.
|
||||
/// </summary>
|
||||
public bool CreateDecoder(VideoProfile profile, int width, int height)
|
||||
{
|
||||
if (!_initialized || _currentApi == VideoAccelerationApi.Software)
|
||||
return false;
|
||||
|
||||
if (!_supportedProfiles.Contains(profile))
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] Profile {profile} not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Destroy existing context
|
||||
DestroyDecoder();
|
||||
|
||||
_width = width;
|
||||
_height = height;
|
||||
_profile = profile;
|
||||
|
||||
if (_currentApi == VideoAccelerationApi.VaApi)
|
||||
return CreateVaApiDecoder(profile, width, height);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CreateVaApiDecoder(VideoProfile profile, int width, int height)
|
||||
{
|
||||
int vaProfile = MapToVaProfile(profile);
|
||||
|
||||
// Create config
|
||||
int status = vaCreateConfig(_vaDisplay, vaProfile, VAEntrypointVLD, IntPtr.Zero, 0, out _vaConfigId);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] vaCreateConfig failed: {GetVaError(status)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create surfaces for decoded frames (use a pool of 8)
|
||||
uint format = profile == VideoProfile.H265Main10 || profile == VideoProfile.Vp9Profile2
|
||||
? VA_RT_FORMAT_YUV420_10
|
||||
: VA_RT_FORMAT_YUV420;
|
||||
|
||||
_vaSurfaces = new uint[8];
|
||||
status = vaCreateSurfaces(_vaDisplay, format, (uint)width, (uint)height, _vaSurfaces, 8, IntPtr.Zero, 0);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] vaCreateSurfaces failed: {GetVaError(status)}");
|
||||
vaDestroyConfig(_vaDisplay, _vaConfigId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create context
|
||||
status = vaCreateContext(_vaDisplay, _vaConfigId, width, height, 0, IntPtr.Zero, 0, out _vaContextId);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
{
|
||||
Console.WriteLine($"[HardwareVideo] vaCreateContext failed: {GetVaError(status)}");
|
||||
vaDestroySurfaces(_vaDisplay, _vaSurfaces, _vaSurfaces.Length);
|
||||
vaDestroyConfig(_vaDisplay, _vaConfigId);
|
||||
return false;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[HardwareVideo] Created decoder: {profile} {width}x{height}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the current decoder context.
|
||||
/// </summary>
|
||||
public void DestroyDecoder()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentApi == VideoAccelerationApi.VaApi && _vaDisplay != IntPtr.Zero)
|
||||
{
|
||||
if (_vaContextId != 0)
|
||||
{
|
||||
vaDestroyContext(_vaDisplay, _vaContextId);
|
||||
_vaContextId = 0;
|
||||
}
|
||||
|
||||
if (_vaSurfaces.Length > 0)
|
||||
{
|
||||
vaDestroySurfaces(_vaDisplay, _vaSurfaces, _vaSurfaces.Length);
|
||||
_vaSurfaces = Array.Empty<uint>();
|
||||
}
|
||||
|
||||
if (_vaConfigId != 0)
|
||||
{
|
||||
vaDestroyConfig(_vaDisplay, _vaConfigId);
|
||||
_vaConfigId = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Frame Retrieval
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a decoded frame from the specified surface.
|
||||
/// </summary>
|
||||
public VideoFrame? GetDecodedFrame(int surfaceIndex, long timestamp, bool isKeyFrame)
|
||||
{
|
||||
if (!_initialized || _currentApi != VideoAccelerationApi.VaApi)
|
||||
return null;
|
||||
|
||||
if (surfaceIndex < 0 || surfaceIndex >= _vaSurfaces.Length)
|
||||
return null;
|
||||
|
||||
uint surfaceId = _vaSurfaces[surfaceIndex];
|
||||
|
||||
// Wait for decode to complete
|
||||
int status = vaSyncSurface(_vaDisplay, surfaceId);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
return null;
|
||||
|
||||
// Derive image from surface
|
||||
status = vaDeriveImage(_vaDisplay, surfaceId, out VaImage image);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
return null;
|
||||
|
||||
// Map the buffer
|
||||
status = vaMapBuffer(_vaDisplay, image.BufferId, out IntPtr data);
|
||||
if (status != VA_STATUS_SUCCESS)
|
||||
{
|
||||
vaDestroyImage(_vaDisplay, image.ImageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var frame = new VideoFrame
|
||||
{
|
||||
Width = image.Width,
|
||||
Height = image.Height,
|
||||
DataY = data + (int)image.OffsetsPlane0,
|
||||
DataU = data + (int)image.OffsetsPlane1,
|
||||
DataV = data + (int)image.OffsetsPlane2,
|
||||
StrideY = (int)image.PitchesPlane0,
|
||||
StrideU = (int)image.PitchesPlane1,
|
||||
StrideV = (int)image.PitchesPlane2,
|
||||
Timestamp = timestamp,
|
||||
IsKeyFrame = isKeyFrame
|
||||
};
|
||||
|
||||
// Set cleanup callback
|
||||
frame.SetReleaseCallback(() =>
|
||||
{
|
||||
vaUnmapBuffer(_vaDisplay, image.BufferId);
|
||||
vaDestroyImage(_vaDisplay, image.ImageId);
|
||||
});
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a decoded frame to an SKBitmap for display.
|
||||
/// </summary>
|
||||
public SKBitmap? ConvertFrameToSkia(VideoFrame frame)
|
||||
{
|
||||
if (frame == null)
|
||||
return null;
|
||||
|
||||
// Create BGRA bitmap
|
||||
var bitmap = new SKBitmap(frame.Width, frame.Height, SKColorType.Bgra8888, SKAlphaType.Opaque);
|
||||
|
||||
// Convert YUV to BGRA
|
||||
unsafe
|
||||
{
|
||||
byte* yPtr = (byte*)frame.DataY;
|
||||
byte* uPtr = (byte*)frame.DataU;
|
||||
byte* vPtr = (byte*)frame.DataV;
|
||||
byte* dst = (byte*)bitmap.GetPixels();
|
||||
|
||||
for (int y = 0; y < frame.Height; y++)
|
||||
{
|
||||
for (int x = 0; x < frame.Width; x++)
|
||||
{
|
||||
int yIndex = y * frame.StrideY + x;
|
||||
int uvIndex = (y / 2) * frame.StrideU + (x / 2);
|
||||
|
||||
int yVal = yPtr[yIndex];
|
||||
int uVal = uPtr[uvIndex] - 128;
|
||||
int vVal = vPtr[uvIndex] - 128;
|
||||
|
||||
// YUV to RGB conversion
|
||||
int r = (int)(yVal + 1.402 * vVal);
|
||||
int g = (int)(yVal - 0.344 * uVal - 0.714 * vVal);
|
||||
int b = (int)(yVal + 1.772 * uVal);
|
||||
|
||||
r = Math.Clamp(r, 0, 255);
|
||||
g = Math.Clamp(g, 0, 255);
|
||||
b = Math.Clamp(b, 0, 255);
|
||||
|
||||
int dstIndex = (y * frame.Width + x) * 4;
|
||||
dst[dstIndex] = (byte)b;
|
||||
dst[dstIndex + 1] = (byte)g;
|
||||
dst[dstIndex + 2] = (byte)r;
|
||||
dst[dstIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static bool TryMapVaProfile(int vaProfile, out VideoProfile profile)
|
||||
{
|
||||
profile = vaProfile switch
|
||||
{
|
||||
VAProfileH264Baseline => VideoProfile.H264Baseline,
|
||||
VAProfileH264Main => VideoProfile.H264Main,
|
||||
VAProfileH264High => VideoProfile.H264High,
|
||||
VAProfileHEVCMain => VideoProfile.H265Main,
|
||||
VAProfileHEVCMain10 => VideoProfile.H265Main10,
|
||||
VAProfileVP8Version0_3 => VideoProfile.Vp8,
|
||||
VAProfileVP9Profile0 => VideoProfile.Vp9Profile0,
|
||||
VAProfileVP9Profile2 => VideoProfile.Vp9Profile2,
|
||||
VAProfileAV1Profile0 => VideoProfile.Av1Main,
|
||||
_ => VideoProfile.H264Main
|
||||
};
|
||||
|
||||
return vaProfile >= VAProfileH264Baseline && vaProfile <= VAProfileAV1Profile0;
|
||||
}
|
||||
|
||||
private static int MapToVaProfile(VideoProfile profile)
|
||||
{
|
||||
return profile switch
|
||||
{
|
||||
VideoProfile.H264Baseline => VAProfileH264Baseline,
|
||||
VideoProfile.H264Main => VAProfileH264Main,
|
||||
VideoProfile.H264High => VAProfileH264High,
|
||||
VideoProfile.H265Main => VAProfileHEVCMain,
|
||||
VideoProfile.H265Main10 => VAProfileHEVCMain10,
|
||||
VideoProfile.Vp8 => VAProfileVP8Version0_3,
|
||||
VideoProfile.Vp9Profile0 => VAProfileVP9Profile0,
|
||||
VideoProfile.Vp9Profile2 => VAProfileVP9Profile2,
|
||||
VideoProfile.Av1Main => VAProfileAV1Profile0,
|
||||
_ => VAProfileH264Main
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVaError(int status)
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr errPtr = vaErrorStr(status);
|
||||
return Marshal.PtrToStringAnsi(errPtr) ?? $"Unknown error {status}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return $"Error code {status}";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
DestroyDecoder();
|
||||
|
||||
if (_currentApi == VideoAccelerationApi.VaApi && _vaDisplay != IntPtr.Zero)
|
||||
{
|
||||
vaTerminate(_vaDisplay);
|
||||
_vaDisplay = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (_drmFd >= 0)
|
||||
{
|
||||
close(_drmFd);
|
||||
_drmFd = -1;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~HardwareVideoService()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Controls.Internals;
|
||||
|
||||
[assembly: Dependency(typeof(Microsoft.Maui.Platform.Linux.Services.LinuxResourcesProvider))]
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides system resources for the Linux platform.
|
||||
/// </summary>
|
||||
internal sealed class LinuxResourcesProvider : ISystemResourcesProvider
|
||||
{
|
||||
private ResourceDictionary? _dictionary;
|
||||
|
||||
public IResourceDictionary GetSystemResources()
|
||||
{
|
||||
_dictionary ??= CreateResourceDictionary();
|
||||
return _dictionary;
|
||||
}
|
||||
|
||||
private ResourceDictionary CreateResourceDictionary()
|
||||
{
|
||||
var dictionary = new ResourceDictionary();
|
||||
|
||||
// Add default styles
|
||||
dictionary[Device.Styles.BodyStyleKey] = new Style(typeof(Label));
|
||||
dictionary[Device.Styles.TitleStyleKey] = CreateTitleStyle();
|
||||
dictionary[Device.Styles.SubtitleStyleKey] = CreateSubtitleStyle();
|
||||
dictionary[Device.Styles.CaptionStyleKey] = CreateCaptionStyle();
|
||||
dictionary[Device.Styles.ListItemTextStyleKey] = new Style(typeof(Label));
|
||||
dictionary[Device.Styles.ListItemDetailTextStyleKey] = CreateCaptionStyle();
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static Style CreateTitleStyle() => new(typeof(Label))
|
||||
{
|
||||
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 24.0 } }
|
||||
};
|
||||
|
||||
private static Style CreateSubtitleStyle() => new(typeof(Label))
|
||||
{
|
||||
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 18.0 } }
|
||||
};
|
||||
|
||||
private static Style CreateCaptionStyle() => new(typeof(Label))
|
||||
{
|
||||
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 12.0 } }
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Static helper for system clipboard access using xclip/xsel.
|
||||
/// Provides synchronous access for use in UI event handlers.
|
||||
/// </summary>
|
||||
public static class SystemClipboard
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets text from the system clipboard.
|
||||
/// </summary>
|
||||
public static string? GetText()
|
||||
{
|
||||
// Try xclip first
|
||||
var result = TryGetWithXclip();
|
||||
if (result != null) return result;
|
||||
|
||||
// Try xsel as fallback
|
||||
result = TryGetWithXsel();
|
||||
if (result != null) return result;
|
||||
|
||||
// Try wl-paste for Wayland
|
||||
return TryGetWithWlPaste();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets text to the system clipboard.
|
||||
/// </summary>
|
||||
public static void SetText(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
ClearClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try xclip first
|
||||
if (TrySetWithXclip(text)) return;
|
||||
|
||||
// Try xsel as fallback
|
||||
if (TrySetWithXsel(text)) return;
|
||||
|
||||
// Try wl-copy for Wayland
|
||||
TrySetWithWlCopy(text);
|
||||
}
|
||||
|
||||
private static string? TryGetWithXclip()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xclip",
|
||||
Arguments = "-selection clipboard -o",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(1000);
|
||||
|
||||
return process.ExitCode == 0 ? output : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetWithXsel()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xsel",
|
||||
Arguments = "--clipboard --output",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(1000);
|
||||
|
||||
return process.ExitCode == 0 ? output : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetWithWlPaste()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "wl-paste",
|
||||
Arguments = "--no-newline",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(1000);
|
||||
|
||||
return process.ExitCode == 0 ? output : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySetWithXclip(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xclip",
|
||||
Arguments = "-selection clipboard",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
process.StandardInput.Write(text);
|
||||
process.StandardInput.Close();
|
||||
|
||||
process.WaitForExit(1000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySetWithXsel(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xsel",
|
||||
Arguments = "--clipboard --input",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
process.StandardInput.Write(text);
|
||||
process.StandardInput.Close();
|
||||
|
||||
process.WaitForExit(1000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySetWithWlCopy(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "wl-copy",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
process.StandardInput.Write(text);
|
||||
process.StandardInput.Close();
|
||||
|
||||
process.WaitForExit(1000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try xclip
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "xclip",
|
||||
Arguments = "-selection clipboard",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process != null)
|
||||
{
|
||||
process.StandardInput.Close();
|
||||
process.WaitForExit(1000);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when clearing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,38 +6,169 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered activity indicator (spinner) control.
|
||||
/// Skia-rendered activity indicator (spinner) control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaActivityIndicator : SkiaView
|
||||
{
|
||||
private bool _isRunning;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsRunning.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsRunningProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsRunning),
|
||||
typeof(bool),
|
||||
typeof(SkiaActivityIndicator),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).OnIsRunningChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Color.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Color),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaActivityIndicator),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaActivityIndicator),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Size.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Size),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
32f,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for StrokeWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty StrokeWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(StrokeWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
3f,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for RotationSpeed.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty RotationSpeedProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(RotationSpeed),
|
||||
typeof(float),
|
||||
typeof(SkiaActivityIndicator),
|
||||
360f);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ArcCount.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ArcCountProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ArcCount),
|
||||
typeof(int),
|
||||
typeof(SkiaActivityIndicator),
|
||||
12,
|
||||
propertyChanged: (b, o, n) => ((SkiaActivityIndicator)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the indicator is running.
|
||||
/// </summary>
|
||||
public bool IsRunning
|
||||
{
|
||||
get => (bool)GetValue(IsRunningProperty);
|
||||
set => SetValue(IsRunningProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the indicator color.
|
||||
/// </summary>
|
||||
public SKColor Color
|
||||
{
|
||||
get => (SKColor)GetValue(ColorProperty);
|
||||
set => SetValue(ColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the indicator size.
|
||||
/// </summary>
|
||||
public float Size
|
||||
{
|
||||
get => (float)GetValue(SizeProperty);
|
||||
set => SetValue(SizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stroke width.
|
||||
/// </summary>
|
||||
public float StrokeWidth
|
||||
{
|
||||
get => (float)GetValue(StrokeWidthProperty);
|
||||
set => SetValue(StrokeWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the rotation speed in degrees per second.
|
||||
/// </summary>
|
||||
public float RotationSpeed
|
||||
{
|
||||
get => (float)GetValue(RotationSpeedProperty);
|
||||
set => SetValue(RotationSpeedProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of arcs.
|
||||
/// </summary>
|
||||
public int ArcCount
|
||||
{
|
||||
get => (int)GetValue(ArcCountProperty);
|
||||
set => SetValue(ArcCountProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private float _rotationAngle;
|
||||
private DateTime _lastUpdateTime = DateTime.UtcNow;
|
||||
|
||||
public bool IsRunning
|
||||
private void OnIsRunningChanged()
|
||||
{
|
||||
get => _isRunning;
|
||||
set
|
||||
if (IsRunning)
|
||||
{
|
||||
if (_isRunning != value)
|
||||
{
|
||||
_isRunning = value;
|
||||
if (value)
|
||||
{
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
_lastUpdateTime = DateTime.UtcNow;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public SKColor Color { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float Size { get; set; } = 32;
|
||||
public float StrokeWidth { get; set; } = 3;
|
||||
public float RotationSpeed { get; set; } = 360; // Degrees per second
|
||||
public int ArcCount { get; set; } = 12;
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
if (!IsRunning && !IsEnabled)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// A modal alert dialog rendered with Skia.
|
||||
/// Supports title, message, and up to two buttons (cancel/accept).
|
||||
/// </summary>
|
||||
public class SkiaAlertDialog : SkiaView
|
||||
{
|
||||
private readonly string _title;
|
||||
private readonly string _message;
|
||||
private readonly string? _cancel;
|
||||
private readonly string? _accept;
|
||||
private readonly TaskCompletionSource<bool> _tcs;
|
||||
|
||||
private SKRect _cancelButtonBounds;
|
||||
private SKRect _acceptButtonBounds;
|
||||
private bool _cancelHovered;
|
||||
private bool _acceptHovered;
|
||||
|
||||
// Dialog styling
|
||||
private static readonly SKColor OverlayColor = new SKColor(0, 0, 0, 128);
|
||||
private static readonly SKColor DialogBackground = SKColors.White;
|
||||
private static readonly SKColor TitleColor = new SKColor(0x21, 0x21, 0x21);
|
||||
private static readonly SKColor MessageColor = new SKColor(0x61, 0x61, 0x61);
|
||||
private static readonly SKColor ButtonColor = new SKColor(0x21, 0x96, 0xF3);
|
||||
private static readonly SKColor ButtonHoverColor = new SKColor(0x19, 0x76, 0xD2);
|
||||
private static readonly SKColor ButtonTextColor = SKColors.White;
|
||||
private static readonly SKColor CancelButtonColor = new SKColor(0x9E, 0x9E, 0x9E);
|
||||
private static readonly SKColor CancelButtonHoverColor = new SKColor(0x75, 0x75, 0x75);
|
||||
private static readonly SKColor BorderColor = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
|
||||
private const float DialogWidth = 400;
|
||||
private const float DialogPadding = 24;
|
||||
private const float ButtonHeight = 44;
|
||||
private const float ButtonSpacing = 12;
|
||||
private const float CornerRadius = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new alert dialog.
|
||||
/// </summary>
|
||||
public SkiaAlertDialog(string title, string message, string? accept, string? cancel)
|
||||
{
|
||||
_title = title;
|
||||
_message = message;
|
||||
_accept = accept;
|
||||
_cancel = cancel;
|
||||
_tcs = new TaskCompletionSource<bool>();
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task that completes when the dialog is dismissed.
|
||||
/// Returns true if accept was clicked, false if cancel was clicked.
|
||||
/// </summary>
|
||||
public Task<bool> Result => _tcs.Task;
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw semi-transparent overlay covering entire screen
|
||||
using var overlayPaint = new SKPaint
|
||||
{
|
||||
Color = OverlayColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, overlayPaint);
|
||||
|
||||
// Calculate dialog dimensions
|
||||
var messageLines = WrapText(_message, DialogWidth - DialogPadding * 2, 16);
|
||||
var dialogHeight = CalculateDialogHeight(messageLines.Count);
|
||||
|
||||
var dialogLeft = bounds.MidX - DialogWidth / 2;
|
||||
var dialogTop = bounds.MidY - dialogHeight / 2;
|
||||
var dialogBounds = new SKRect(dialogLeft, dialogTop, dialogLeft + DialogWidth, dialogTop + dialogHeight);
|
||||
|
||||
// Draw dialog shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 60),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 8),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var shadowRect = new SKRect(dialogBounds.Left + 4, dialogBounds.Top + 4,
|
||||
dialogBounds.Right + 4, dialogBounds.Bottom + 4);
|
||||
canvas.DrawRoundRect(shadowRect, CornerRadius, CornerRadius, shadowPaint);
|
||||
|
||||
// Draw dialog background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = DialogBackground,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(dialogBounds, CornerRadius, CornerRadius, bgPaint);
|
||||
|
||||
// Draw title
|
||||
var yOffset = dialogBounds.Top + DialogPadding;
|
||||
if (!string.IsNullOrEmpty(_title))
|
||||
{
|
||||
using var titleFont = new SKFont(SKTypeface.Default, 20) { Embolden = true };
|
||||
using var titlePaint = new SKPaint(titleFont)
|
||||
{
|
||||
Color = TitleColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawText(_title, dialogBounds.Left + DialogPadding, yOffset + 20, titlePaint);
|
||||
yOffset += 36;
|
||||
}
|
||||
|
||||
// Draw message
|
||||
if (!string.IsNullOrEmpty(_message))
|
||||
{
|
||||
using var messageFont = new SKFont(SKTypeface.Default, 16);
|
||||
using var messagePaint = new SKPaint(messageFont)
|
||||
{
|
||||
Color = MessageColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
foreach (var line in messageLines)
|
||||
{
|
||||
canvas.DrawText(line, dialogBounds.Left + DialogPadding, yOffset + 16, messagePaint);
|
||||
yOffset += 22;
|
||||
}
|
||||
yOffset += 8;
|
||||
}
|
||||
|
||||
// Draw buttons
|
||||
yOffset = dialogBounds.Bottom - DialogPadding - ButtonHeight;
|
||||
var buttonY = yOffset;
|
||||
|
||||
var buttonCount = (_accept != null ? 1 : 0) + (_cancel != null ? 1 : 0);
|
||||
var totalButtonWidth = DialogWidth - DialogPadding * 2;
|
||||
|
||||
if (buttonCount == 2)
|
||||
{
|
||||
var singleButtonWidth = (totalButtonWidth - ButtonSpacing) / 2;
|
||||
|
||||
// Cancel button (left)
|
||||
_cancelButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Left + DialogPadding + singleButtonWidth,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _cancelButtonBounds, _cancel!,
|
||||
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
|
||||
|
||||
// Accept button (right)
|
||||
_acceptButtonBounds = new SKRect(
|
||||
dialogBounds.Right - DialogPadding - singleButtonWidth,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _acceptButtonBounds, _accept!,
|
||||
_acceptHovered ? ButtonHoverColor : ButtonColor);
|
||||
}
|
||||
else if (_accept != null)
|
||||
{
|
||||
_acceptButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _acceptButtonBounds, _accept,
|
||||
_acceptHovered ? ButtonHoverColor : ButtonColor);
|
||||
}
|
||||
else if (_cancel != null)
|
||||
{
|
||||
_cancelButtonBounds = new SKRect(
|
||||
dialogBounds.Left + DialogPadding,
|
||||
buttonY,
|
||||
dialogBounds.Right - DialogPadding,
|
||||
buttonY + ButtonHeight);
|
||||
DrawButton(canvas, _cancelButtonBounds, _cancel,
|
||||
_cancelHovered ? CancelButtonHoverColor : CancelButtonColor);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawButton(SKCanvas canvas, SKRect bounds, string text, SKColor bgColor)
|
||||
{
|
||||
// Button background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(bounds, 8, 8, bgPaint);
|
||||
|
||||
// Button text
|
||||
using var font = new SKFont(SKTypeface.Default, 16) { Embolden = true };
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = ButtonTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(text, ref textBounds);
|
||||
|
||||
var x = bounds.MidX - textBounds.MidX;
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(text, x, y, textPaint);
|
||||
}
|
||||
|
||||
private float CalculateDialogHeight(int messageLineCount)
|
||||
{
|
||||
var height = DialogPadding * 2; // Top and bottom padding
|
||||
|
||||
if (!string.IsNullOrEmpty(_title))
|
||||
height += 36; // Title height
|
||||
|
||||
if (!string.IsNullOrEmpty(_message))
|
||||
height += messageLineCount * 22 + 8; // Message lines + spacing
|
||||
|
||||
height += ButtonHeight; // Buttons
|
||||
|
||||
return Math.Max(height, 180); // Minimum height
|
||||
}
|
||||
|
||||
private List<string> WrapText(string text, float maxWidth, float fontSize)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return lines;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, fontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
var words = text.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var width = paint.MeasureText(testLine);
|
||||
|
||||
if (width > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
lines.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
lines.Add(currentLine);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
var wasHovered = _cancelHovered || _acceptHovered;
|
||||
|
||||
_cancelHovered = _cancel != null && _cancelButtonBounds.Contains(e.X, e.Y);
|
||||
_acceptHovered = _accept != null && _acceptButtonBounds.Contains(e.X, e.Y);
|
||||
|
||||
if (wasHovered != (_cancelHovered || _acceptHovered))
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
// Check if clicking on buttons
|
||||
if (_cancel != null && _cancelButtonBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
Dismiss(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_accept != null && _acceptButtonBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
Dismiss(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicking outside dialog doesn't dismiss it (it's modal)
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
// Handle Escape to cancel
|
||||
if (e.Key == Key.Escape && _cancel != null)
|
||||
{
|
||||
Dismiss(false);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Enter to accept
|
||||
if (e.Key == Key.Enter && _accept != null)
|
||||
{
|
||||
Dismiss(true);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void Dismiss(bool result)
|
||||
{
|
||||
// Remove from dialog system
|
||||
LinuxDialogService.HideDialog(this);
|
||||
_tcs.TrySetResult(result);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Dialog takes full screen for the overlay
|
||||
return availableSize;
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
// Modal dialogs capture all input
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for showing modal dialogs in OpenMaui Linux.
|
||||
/// </summary>
|
||||
public static class LinuxDialogService
|
||||
{
|
||||
private static readonly List<SkiaAlertDialog> _activeDialogs = new();
|
||||
private static Action? _invalidateCallback;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the invalidation callback (called by LinuxApplication).
|
||||
/// </summary>
|
||||
public static void SetInvalidateCallback(Action callback)
|
||||
{
|
||||
_invalidateCallback = callback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an alert dialog and returns when dismissed.
|
||||
/// </summary>
|
||||
public static Task<bool> ShowAlertAsync(string title, string message, string? accept, string? cancel)
|
||||
{
|
||||
var dialog = new SkiaAlertDialog(title, message, accept, cancel);
|
||||
_activeDialogs.Add(dialog);
|
||||
_invalidateCallback?.Invoke();
|
||||
return dialog.Result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides a dialog.
|
||||
/// </summary>
|
||||
internal static void HideDialog(SkiaAlertDialog dialog)
|
||||
{
|
||||
_activeDialogs.Remove(dialog);
|
||||
_invalidateCallback?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are active dialogs.
|
||||
/// </summary>
|
||||
public static bool HasActiveDialog => _activeDialogs.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the topmost dialog.
|
||||
/// </summary>
|
||||
public static SkiaAlertDialog? TopDialog => _activeDialogs.Count > 0 ? _activeDialogs[^1] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Draws all active dialogs.
|
||||
/// </summary>
|
||||
public static void DrawDialogs(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
foreach (var dialog in _activeDialogs)
|
||||
{
|
||||
dialog.Measure(new SKSize(bounds.Width, bounds.Height));
|
||||
dialog.Arrange(bounds);
|
||||
dialog.Draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,99 +6,192 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered border/frame container control.
|
||||
/// Skia-rendered border/frame container control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaBorder : SkiaLayoutView
|
||||
{
|
||||
private float _strokeThickness = 1;
|
||||
private float _cornerRadius = 0;
|
||||
private SKColor _stroke = SKColors.Black;
|
||||
private float _paddingLeft = 0;
|
||||
private float _paddingTop = 0;
|
||||
private float _paddingRight = 0;
|
||||
private float _paddingBottom = 0;
|
||||
private bool _hasShadow;
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty StrokeThicknessProperty =
|
||||
BindableProperty.Create(nameof(StrokeThickness), typeof(float), typeof(SkiaBorder), 1f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty StrokeProperty =
|
||||
BindableProperty.Create(nameof(Stroke), typeof(SKColor), typeof(SkiaBorder), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty PaddingLeftProperty =
|
||||
BindableProperty.Create(nameof(PaddingLeft), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingTopProperty =
|
||||
BindableProperty.Create(nameof(PaddingTop), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingRightProperty =
|
||||
BindableProperty.Create(nameof(PaddingRight), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingBottomProperty =
|
||||
BindableProperty.Create(nameof(PaddingBottom), typeof(float), typeof(SkiaBorder), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty HasShadowProperty =
|
||||
BindableProperty.Create(nameof(HasShadow), typeof(bool), typeof(SkiaBorder), false,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowColorProperty =
|
||||
BindableProperty.Create(nameof(ShadowColor), typeof(SKColor), typeof(SkiaBorder), new SKColor(0, 0, 0, 40),
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowBlurRadiusProperty =
|
||||
BindableProperty.Create(nameof(ShadowBlurRadius), typeof(float), typeof(SkiaBorder), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowOffsetXProperty =
|
||||
BindableProperty.Create(nameof(ShadowOffsetX), typeof(float), typeof(SkiaBorder), 2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ShadowOffsetYProperty =
|
||||
BindableProperty.Create(nameof(ShadowOffsetY), typeof(float), typeof(SkiaBorder), 2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBorder)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public float StrokeThickness
|
||||
{
|
||||
get => _strokeThickness;
|
||||
set { _strokeThickness = value; Invalidate(); }
|
||||
get => (float)GetValue(StrokeThicknessProperty);
|
||||
set => SetValue(StrokeThicknessProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => _cornerRadius;
|
||||
set { _cornerRadius = value; Invalidate(); }
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public SKColor Stroke
|
||||
{
|
||||
get => _stroke;
|
||||
set { _stroke = value; Invalidate(); }
|
||||
get => (SKColor)GetValue(StrokeProperty);
|
||||
set => SetValue(StrokeProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingLeft
|
||||
{
|
||||
get => _paddingLeft;
|
||||
set { _paddingLeft = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingLeftProperty);
|
||||
set => SetValue(PaddingLeftProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingTop
|
||||
{
|
||||
get => _paddingTop;
|
||||
set { _paddingTop = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingTopProperty);
|
||||
set => SetValue(PaddingTopProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingRight
|
||||
{
|
||||
get => _paddingRight;
|
||||
set { _paddingRight = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingRightProperty);
|
||||
set => SetValue(PaddingRightProperty, value);
|
||||
}
|
||||
|
||||
public float PaddingBottom
|
||||
{
|
||||
get => _paddingBottom;
|
||||
set { _paddingBottom = value; InvalidateMeasure(); }
|
||||
get => (float)GetValue(PaddingBottomProperty);
|
||||
set => SetValue(PaddingBottomProperty, value);
|
||||
}
|
||||
|
||||
public bool HasShadow
|
||||
{
|
||||
get => _hasShadow;
|
||||
set { _hasShadow = value; Invalidate(); }
|
||||
get => (bool)GetValue(HasShadowProperty);
|
||||
set => SetValue(HasShadowProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ShadowColor
|
||||
{
|
||||
get => (SKColor)GetValue(ShadowColorProperty);
|
||||
set => SetValue(ShadowColorProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowBlurRadius
|
||||
{
|
||||
get => (float)GetValue(ShadowBlurRadiusProperty);
|
||||
set => SetValue(ShadowBlurRadiusProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowOffsetX
|
||||
{
|
||||
get => (float)GetValue(ShadowOffsetXProperty);
|
||||
set => SetValue(ShadowOffsetXProperty, value);
|
||||
}
|
||||
|
||||
public float ShadowOffsetY
|
||||
{
|
||||
get => (float)GetValue(ShadowOffsetYProperty);
|
||||
set => SetValue(ShadowOffsetYProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Sets uniform padding on all sides.
|
||||
/// </summary>
|
||||
public void SetPadding(float all)
|
||||
{
|
||||
_paddingLeft = _paddingTop = _paddingRight = _paddingBottom = all;
|
||||
InvalidateMeasure();
|
||||
PaddingLeft = PaddingTop = PaddingRight = PaddingBottom = all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets padding with horizontal and vertical values.
|
||||
/// </summary>
|
||||
public void SetPadding(float horizontal, float vertical)
|
||||
{
|
||||
_paddingLeft = _paddingRight = horizontal;
|
||||
_paddingTop = _paddingBottom = vertical;
|
||||
InvalidateMeasure();
|
||||
PaddingLeft = PaddingRight = horizontal;
|
||||
PaddingTop = PaddingBottom = vertical;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets padding with individual values for each side.
|
||||
/// </summary>
|
||||
public void SetPadding(float left, float top, float right, float bottom)
|
||||
{
|
||||
PaddingLeft = left;
|
||||
PaddingTop = top;
|
||||
PaddingRight = right;
|
||||
PaddingBottom = bottom;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var strokeThickness = StrokeThickness;
|
||||
var cornerRadius = CornerRadius;
|
||||
|
||||
var borderRect = new SKRect(
|
||||
bounds.Left + _strokeThickness / 2,
|
||||
bounds.Top + _strokeThickness / 2,
|
||||
bounds.Right - _strokeThickness / 2,
|
||||
bounds.Bottom - _strokeThickness / 2);
|
||||
bounds.Left + strokeThickness / 2,
|
||||
bounds.Top + strokeThickness / 2,
|
||||
bounds.Right - strokeThickness / 2,
|
||||
bounds.Bottom - strokeThickness / 2);
|
||||
|
||||
// Draw shadow if enabled
|
||||
if (_hasShadow)
|
||||
if (HasShadow)
|
||||
{
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4),
|
||||
Color = ShadowColor,
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlurRadius),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var shadowRect = new SKRect(borderRect.Left + 2, borderRect.Top + 2, borderRect.Right + 2, borderRect.Bottom + 2);
|
||||
canvas.DrawRoundRect(new SKRoundRect(shadowRect, _cornerRadius), shadowPaint);
|
||||
var shadowRect = new SKRect(
|
||||
borderRect.Left + ShadowOffsetX,
|
||||
borderRect.Top + ShadowOffsetY,
|
||||
borderRect.Right + ShadowOffsetX,
|
||||
borderRect.Bottom + ShadowOffsetY);
|
||||
canvas.DrawRoundRect(new SKRoundRect(shadowRect, cornerRadius), shadowPaint);
|
||||
}
|
||||
|
||||
// Draw background
|
||||
|
|
@ -108,22 +201,22 @@ public class SkiaBorder : SkiaLayoutView
|
|||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, _cornerRadius), bgPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, cornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
if (_strokeThickness > 0)
|
||||
if (strokeThickness > 0)
|
||||
{
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = _stroke,
|
||||
Color = Stroke,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = _strokeThickness,
|
||||
StrokeWidth = strokeThickness,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, _cornerRadius), borderPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(borderRect, cornerRadius), borderPaint);
|
||||
}
|
||||
|
||||
// Draw children (call base which draws children)
|
||||
// Draw children
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child.IsVisible)
|
||||
|
|
@ -140,21 +233,27 @@ public class SkiaBorder : SkiaLayoutView
|
|||
|
||||
protected new SKRect GetContentBounds(SKRect bounds)
|
||||
{
|
||||
var strokeThickness = StrokeThickness;
|
||||
return new SKRect(
|
||||
bounds.Left + _paddingLeft + _strokeThickness,
|
||||
bounds.Top + _paddingTop + _strokeThickness,
|
||||
bounds.Right - _paddingRight - _strokeThickness,
|
||||
bounds.Bottom - _paddingBottom - _strokeThickness);
|
||||
bounds.Left + PaddingLeft + strokeThickness,
|
||||
bounds.Top + PaddingTop + strokeThickness,
|
||||
bounds.Right - PaddingRight - strokeThickness,
|
||||
bounds.Bottom - PaddingBottom - strokeThickness);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var paddingWidth = _paddingLeft + _paddingRight + _strokeThickness * 2;
|
||||
var paddingHeight = _paddingTop + _paddingBottom + _strokeThickness * 2;
|
||||
var strokeThickness = StrokeThickness;
|
||||
var paddingWidth = PaddingLeft + PaddingRight + strokeThickness * 2;
|
||||
var paddingHeight = PaddingTop + PaddingBottom + strokeThickness * 2;
|
||||
|
||||
// Respect explicit size requests
|
||||
var requestedWidth = WidthRequest >= 0 ? (float)WidthRequest : availableSize.Width;
|
||||
var requestedHeight = HeightRequest >= 0 ? (float)HeightRequest : availableSize.Height;
|
||||
|
||||
var childAvailable = new SKSize(
|
||||
availableSize.Width - paddingWidth,
|
||||
availableSize.Height - paddingHeight);
|
||||
Math.Max(0, requestedWidth - paddingWidth),
|
||||
Math.Max(0, requestedHeight - paddingHeight));
|
||||
|
||||
var maxChildSize = SKSize.Empty;
|
||||
|
||||
|
|
@ -166,19 +265,27 @@ public class SkiaBorder : SkiaLayoutView
|
|||
Math.Max(maxChildSize.Height, childSize.Height));
|
||||
}
|
||||
|
||||
return new SKSize(
|
||||
maxChildSize.Width + paddingWidth,
|
||||
maxChildSize.Height + paddingHeight);
|
||||
// Use requested size if set, otherwise use child size + padding
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest : maxChildSize.Width + paddingWidth;
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest : maxChildSize.Height + paddingHeight;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
|
||||
var contentBounds = GetContentBounds(bounds);
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
child.Arrange(contentBounds);
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
contentBounds.Left + (float)margin.Left,
|
||||
contentBounds.Top + (float)margin.Top,
|
||||
contentBounds.Right - (float)margin.Right,
|
||||
contentBounds.Bottom - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
|
||||
return bounds;
|
||||
|
|
@ -186,7 +293,8 @@ public class SkiaBorder : SkiaLayoutView
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frame control (alias for Border with shadow enabled).
|
||||
/// Frame control - a Border with shadow enabled by default.
|
||||
/// Mimics the MAUI Frame control appearance.
|
||||
/// </summary>
|
||||
public class SkiaFrame : SkiaBorder
|
||||
{
|
||||
|
|
@ -196,5 +304,7 @@ public class SkiaFrame : SkiaBorder
|
|||
CornerRadius = 4;
|
||||
SetPadding(10);
|
||||
BackgroundColor = SKColors.White;
|
||||
Stroke = SKColors.Transparent;
|
||||
StrokeThickness = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered BoxView - a simple colored rectangle.
|
||||
/// </summary>
|
||||
public class SkiaBoxView : SkiaView
|
||||
{
|
||||
public static readonly BindableProperty ColorProperty =
|
||||
BindableProperty.Create(nameof(Color), typeof(SKColor), typeof(SkiaBoxView), SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaBoxView)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaBoxView), 0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaBoxView)b).Invalidate());
|
||||
|
||||
public SKColor Color
|
||||
{
|
||||
get => (SKColor)GetValue(ColorProperty);
|
||||
set => SetValue(ColorProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
Color = Color,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
if (CornerRadius > 0)
|
||||
{
|
||||
canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, paint);
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// BoxView uses explicit size or a default size when in unbounded context
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest :
|
||||
(float.IsInfinity(availableSize.Width) ? 40f : availableSize.Width);
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest :
|
||||
(float.IsInfinity(availableSize.Height) ? 40f : availableSize.Height);
|
||||
|
||||
// Ensure no NaN values
|
||||
if (float.IsNaN(width)) width = 40f;
|
||||
if (float.IsNaN(height)) height = 40f;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,32 +7,382 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered button control.
|
||||
/// Skia-rendered button control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaButton : SkiaView
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SKColor TextColor { get; set; } = SKColors.White;
|
||||
public new SKColor BackgroundColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); // Material Blue
|
||||
public SKColor PressedBackgroundColor { get; set; } = new SKColor(0x19, 0x76, 0xD2);
|
||||
public SKColor DisabledBackgroundColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor HoveredBackgroundColor { get; set; } = new SKColor(0x42, 0xA5, 0xF5);
|
||||
public SKColor BorderColor { get; set; } = SKColors.Transparent;
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsItalic { get; set; }
|
||||
public float CharacterSpacing { get; set; }
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float BorderWidth { get; set; } = 0;
|
||||
public SKRect Padding { get; set; } = new SKRect(16, 8, 16, 8);
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaButton),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ButtonBackgroundColor (distinct from base BackgroundColor).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ButtonBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ButtonBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x21, 0x96, 0xF3), // Material Blue
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for PressedBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PressedBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(PressedBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x19, 0x76, 0xD2),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoveredBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoveredBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoveredBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
new SKColor(0x42, 0xA5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaButton),
|
||||
SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaButton),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsBold.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsBoldProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsBold),
|
||||
typeof(bool),
|
||||
typeof(SkiaButton),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsItalic.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsItalicProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsItalic),
|
||||
typeof(bool),
|
||||
typeof(SkiaButton),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CharacterSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CharacterSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CharacterSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaButton),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(SKRect),
|
||||
typeof(SkiaButton),
|
||||
new SKRect(16, 8, 16, 8),
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Command.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CommandProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Command),
|
||||
typeof(System.Windows.Input.ICommand),
|
||||
typeof(SkiaButton),
|
||||
null,
|
||||
propertyChanged: (b, o, n) => ((SkiaButton)b).OnCommandChanged((System.Windows.Input.ICommand?)o, (System.Windows.Input.ICommand?)n));
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CommandParameter.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CommandParameterProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CommandParameter),
|
||||
typeof(object),
|
||||
typeof(SkiaButton),
|
||||
null);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the button text.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the button background color.
|
||||
/// </summary>
|
||||
public SKColor ButtonBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonBackgroundColorProperty);
|
||||
set => SetValue(ButtonBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pressed background color.
|
||||
/// </summary>
|
||||
public SKColor PressedBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(PressedBackgroundColorProperty);
|
||||
set => SetValue(PressedBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled background color.
|
||||
/// </summary>
|
||||
public SKColor DisabledBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledBackgroundColorProperty);
|
||||
set => SetValue(DisabledBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hovered background color.
|
||||
/// </summary>
|
||||
public SKColor HoveredBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoveredBackgroundColorProperty);
|
||||
set => SetValue(HoveredBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is bold.
|
||||
/// </summary>
|
||||
public bool IsBold
|
||||
{
|
||||
get => (bool)GetValue(IsBoldProperty);
|
||||
set => SetValue(IsBoldProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is italic.
|
||||
/// </summary>
|
||||
public bool IsItalic
|
||||
{
|
||||
get => (bool)GetValue(IsItalicProperty);
|
||||
set => SetValue(IsItalicProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the character spacing.
|
||||
/// </summary>
|
||||
public float CharacterSpacing
|
||||
{
|
||||
get => (float)GetValue(CharacterSpacingProperty);
|
||||
set => SetValue(CharacterSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border width.
|
||||
/// </summary>
|
||||
public float BorderWidth
|
||||
{
|
||||
get => (float)GetValue(BorderWidthProperty);
|
||||
set => SetValue(BorderWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command to execute when clicked.
|
||||
/// </summary>
|
||||
public System.Windows.Input.ICommand? Command
|
||||
{
|
||||
get => (System.Windows.Input.ICommand?)GetValue(CommandProperty);
|
||||
set => SetValue(CommandProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the command parameter.
|
||||
/// </summary>
|
||||
public object? CommandParameter
|
||||
{
|
||||
get => GetValue(CommandParameterProperty);
|
||||
set => SetValue(CommandParameterProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the button is currently pressed.
|
||||
/// </summary>
|
||||
public bool IsPressed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the pointer is currently over the button.
|
||||
/// </summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _focusFromKeyboard;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is clicked.
|
||||
/// </summary>
|
||||
public event EventHandler? Clicked;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is pressed.
|
||||
/// </summary>
|
||||
public event EventHandler? Pressed;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the button is released.
|
||||
/// </summary>
|
||||
public event EventHandler? Released;
|
||||
|
||||
public SkiaButton()
|
||||
|
|
@ -40,30 +390,91 @@ public class SkiaButton : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnTextChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnFontChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnCommandChanged(System.Windows.Input.ICommand? oldCommand, System.Windows.Input.ICommand? newCommand)
|
||||
{
|
||||
if (oldCommand != null)
|
||||
{
|
||||
oldCommand.CanExecuteChanged -= OnCanExecuteChanged;
|
||||
}
|
||||
|
||||
if (newCommand != null)
|
||||
{
|
||||
newCommand.CanExecuteChanged += OnCanExecuteChanged;
|
||||
UpdateIsEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCanExecuteChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateIsEnabled();
|
||||
}
|
||||
|
||||
private void UpdateIsEnabled()
|
||||
{
|
||||
if (Command != null)
|
||||
{
|
||||
IsEnabled = Command.CanExecute(CommandParameter);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Determine background color based on state
|
||||
var bgColor = !IsEnabled ? DisabledBackgroundColor
|
||||
: IsPressed ? PressedBackgroundColor
|
||||
: IsHovered ? HoveredBackgroundColor
|
||||
: BackgroundColor;
|
||||
// Check if this is a "text only" button (transparent background)
|
||||
var isTextOnly = ButtonBackgroundColor.Alpha == 0;
|
||||
|
||||
// Draw shadow (for elevation effect)
|
||||
if (IsEnabled && !IsPressed)
|
||||
// Determine background color based on state
|
||||
SKColor bgColor;
|
||||
if (!IsEnabled)
|
||||
{
|
||||
bgColor = isTextOnly ? SKColors.Transparent : DisabledBackgroundColor;
|
||||
}
|
||||
else if (IsPressed)
|
||||
{
|
||||
// For text-only buttons, use a subtle press effect
|
||||
bgColor = isTextOnly ? new SKColor(0, 0, 0, 20) : PressedBackgroundColor;
|
||||
}
|
||||
else if (IsHovered)
|
||||
{
|
||||
// For text-only buttons, use a subtle hover effect instead of full background
|
||||
bgColor = isTextOnly ? new SKColor(0, 0, 0, 10) : HoveredBackgroundColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
bgColor = ButtonBackgroundColor;
|
||||
}
|
||||
|
||||
// Draw shadow (for elevation effect) - skip for text-only buttons
|
||||
if (IsEnabled && !IsPressed && !isTextOnly)
|
||||
{
|
||||
DrawShadow(canvas, bounds);
|
||||
}
|
||||
|
||||
// Draw background with rounded corners
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
// Create rounded rect for background and border
|
||||
var rect = new SKRoundRect(bounds, CornerRadius);
|
||||
canvas.DrawRoundRect(rect, bgPaint);
|
||||
|
||||
// Draw background with rounded corners (skip if fully transparent)
|
||||
if (bgColor.Alpha > 0)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = bgColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRoundRect(rect, bgPaint);
|
||||
}
|
||||
|
||||
// Draw border
|
||||
if (BorderWidth > 0 && BorderColor != SKColors.Transparent)
|
||||
|
|
@ -104,9 +515,30 @@ public class SkiaButton : SkiaView
|
|||
?? SKTypeface.Default;
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
|
||||
// For text-only buttons, darken text on hover/press for feedback
|
||||
SKColor textColorToUse;
|
||||
if (!IsEnabled)
|
||||
{
|
||||
textColorToUse = TextColor.WithAlpha(128);
|
||||
}
|
||||
else if (isTextOnly && (IsHovered || IsPressed))
|
||||
{
|
||||
// Darken the text color slightly for hover/press feedback
|
||||
textColorToUse = new SKColor(
|
||||
(byte)Math.Max(0, TextColor.Red - 40),
|
||||
(byte)Math.Max(0, TextColor.Green - 40),
|
||||
(byte)Math.Max(0, TextColor.Blue - 40),
|
||||
TextColor.Alpha);
|
||||
}
|
||||
else
|
||||
{
|
||||
textColorToUse = TextColor;
|
||||
}
|
||||
|
||||
using var paint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
Color = textColorToUse,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
|
|
@ -145,6 +577,7 @@ public class SkiaButton : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
IsHovered = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
|
|
@ -155,15 +588,18 @@ public class SkiaButton : SkiaView
|
|||
{
|
||||
IsPressed = false;
|
||||
}
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaButton] OnPointerPressed - Text='{Text}', IsEnabled={IsEnabled}");
|
||||
if (!IsEnabled) return;
|
||||
|
||||
IsPressed = true;
|
||||
_focusFromKeyboard = false;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
Invalidate();
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
|
@ -174,14 +610,18 @@ public class SkiaButton : SkiaView
|
|||
|
||||
var wasPressed = IsPressed;
|
||||
IsPressed = false;
|
||||
SkiaVisualStateManager.GoToState(this, IsHovered ? SkiaVisualStateManager.CommonStates.PointerOver : SkiaVisualStateManager.CommonStates.Normal);
|
||||
Invalidate();
|
||||
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
// Fire click if released within bounds
|
||||
if (wasPressed && Bounds.Contains(new SKPoint(e.X, e.Y)))
|
||||
// Fire click if button was pressed
|
||||
// Note: Hit testing already verified the pointer is over this button,
|
||||
// so we don't need to re-check bounds (which would fail due to coordinate system differences)
|
||||
if (wasPressed)
|
||||
{
|
||||
Clicked?.Invoke(this, EventArgs.Empty);
|
||||
Command?.Execute(CommandParameter);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +633,8 @@ public class SkiaButton : SkiaView
|
|||
if (e.Key == Key.Enter || e.Key == Key.Space)
|
||||
{
|
||||
IsPressed = true;
|
||||
_focusFromKeyboard = true;
|
||||
_focusFromKeyboard = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
Invalidate();
|
||||
Pressed?.Invoke(this, EventArgs.Empty);
|
||||
e.Handled = true;
|
||||
|
|
@ -209,21 +650,36 @@ public class SkiaButton : SkiaView
|
|||
if (IsPressed)
|
||||
{
|
||||
IsPressed = false;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
|
||||
Invalidate();
|
||||
Released?.Invoke(this, EventArgs.Empty);
|
||||
Clicked?.Invoke(this, EventArgs.Empty);
|
||||
Command?.Execute(CommandParameter);
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Ensure we never return NaN - use safe defaults
|
||||
var paddingLeft = float.IsNaN(Padding.Left) ? 16f : Padding.Left;
|
||||
var paddingRight = float.IsNaN(Padding.Right) ? 16f : Padding.Right;
|
||||
var paddingTop = float.IsNaN(Padding.Top) ? 8f : Padding.Top;
|
||||
var paddingBottom = float.IsNaN(Padding.Bottom) ? 8f : Padding.Bottom;
|
||||
var fontSize = float.IsNaN(FontSize) || FontSize <= 0 ? 14f : FontSize;
|
||||
|
||||
if (string.IsNullOrEmpty(Text))
|
||||
{
|
||||
return new SKSize(
|
||||
Padding.Left + Padding.Right + 40, // Minimum width
|
||||
Padding.Top + Padding.Bottom + FontSize);
|
||||
paddingLeft + paddingRight + 40, // Minimum width
|
||||
paddingTop + paddingBottom + fontSize);
|
||||
}
|
||||
|
||||
var fontStyle = new SKFontStyle(
|
||||
|
|
@ -233,14 +689,25 @@ public class SkiaButton : SkiaView
|
|||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var font = new SKFont(typeface, fontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(Text, ref textBounds);
|
||||
|
||||
return new SKSize(
|
||||
textBounds.Width + Padding.Left + Padding.Right,
|
||||
textBounds.Height + Padding.Top + Padding.Bottom);
|
||||
var width = textBounds.Width + paddingLeft + paddingRight;
|
||||
var height = textBounds.Height + paddingTop + paddingBottom;
|
||||
|
||||
// Ensure valid, non-NaN return values
|
||||
if (float.IsNaN(width) || width < 0) width = 72f;
|
||||
if (float.IsNaN(height) || height < 0) height = 30f;
|
||||
|
||||
// Respect WidthRequest and HeightRequest when set
|
||||
if (WidthRequest >= 0)
|
||||
width = (float)WidthRequest;
|
||||
if (HeightRequest >= 0)
|
||||
height = (float)HeightRequest;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,39 +7,247 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered checkbox control.
|
||||
/// Skia-rendered checkbox control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaCheckBox : SkiaView
|
||||
{
|
||||
private bool _isChecked;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsChecked.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsCheckedProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsChecked),
|
||||
typeof(bool),
|
||||
typeof(SkiaCheckBox),
|
||||
false,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).OnIsCheckedChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CheckColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CheckColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CheckColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BoxColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BoxColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BoxColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for UncheckedBoxColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty UncheckedBoxColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(UncheckedBoxColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x75, 0x75, 0x75),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoveredBorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoveredBorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoveredBorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaCheckBox),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BoxSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BoxSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BoxSize),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
20f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
3f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CheckStrokeWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CheckStrokeWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CheckStrokeWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaCheckBox),
|
||||
2.5f,
|
||||
propertyChanged: (b, o, n) => ((SkiaCheckBox)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the checkbox is checked.
|
||||
/// </summary>
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (_isChecked != value)
|
||||
{
|
||||
_isChecked = value;
|
||||
CheckedChanged?.Invoke(this, new CheckedChangedEventArgs(value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsCheckedProperty);
|
||||
set => SetValue(IsCheckedProperty, value);
|
||||
}
|
||||
|
||||
public SKColor CheckColor { get; set; } = SKColors.White;
|
||||
public SKColor BoxColor { get; set; } = new SKColor(0x21, 0x96, 0xF3); // Material Blue
|
||||
public SKColor UncheckedBoxColor { get; set; } = SKColors.White;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0x75, 0x75, 0x75);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor HoveredBorderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public float BoxSize { get; set; } = 20;
|
||||
public float CornerRadius { get; set; } = 3;
|
||||
public float BorderWidth { get; set; } = 2;
|
||||
public float CheckStrokeWidth { get; set; } = 2.5f;
|
||||
/// <summary>
|
||||
/// Gets or sets the check color.
|
||||
/// </summary>
|
||||
public SKColor CheckColor
|
||||
{
|
||||
get => (SKColor)GetValue(CheckColorProperty);
|
||||
set => SetValue(CheckColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box color when checked.
|
||||
/// </summary>
|
||||
public SKColor BoxColor
|
||||
{
|
||||
get => (SKColor)GetValue(BoxColorProperty);
|
||||
set => SetValue(BoxColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box color when unchecked.
|
||||
/// </summary>
|
||||
public SKColor UncheckedBoxColor
|
||||
{
|
||||
get => (SKColor)GetValue(UncheckedBoxColorProperty);
|
||||
set => SetValue(UncheckedBoxColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hovered border color.
|
||||
/// </summary>
|
||||
public SKColor HoveredBorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoveredBorderColorProperty);
|
||||
set => SetValue(HoveredBorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the box size.
|
||||
/// </summary>
|
||||
public float BoxSize
|
||||
{
|
||||
get => (float)GetValue(BoxSizeProperty);
|
||||
set => SetValue(BoxSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border width.
|
||||
/// </summary>
|
||||
public float BorderWidth
|
||||
{
|
||||
get => (float)GetValue(BorderWidthProperty);
|
||||
set => SetValue(BorderWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the check stroke width.
|
||||
/// </summary>
|
||||
public float CheckStrokeWidth
|
||||
{
|
||||
get => (float)GetValue(CheckStrokeWidthProperty);
|
||||
set => SetValue(CheckStrokeWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the pointer is over the checkbox.
|
||||
/// </summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when checked state changes.
|
||||
/// </summary>
|
||||
public event EventHandler<CheckedChangedEventArgs>? CheckedChanged;
|
||||
|
||||
public SkiaCheckBox()
|
||||
|
|
@ -47,6 +255,13 @@ public class SkiaCheckBox : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnIsCheckedChanged()
|
||||
{
|
||||
CheckedChanged?.Invoke(this, new CheckedChangedEventArgs(IsChecked));
|
||||
SkiaVisualStateManager.GoToState(this, IsChecked ? SkiaVisualStateManager.CommonStates.Checked : SkiaVisualStateManager.CommonStates.Unchecked);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Center the checkbox box in bounds
|
||||
|
|
@ -136,12 +351,14 @@ public class SkiaCheckBox : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
IsHovered = true;
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerExited(PointerEventArgs e)
|
||||
{
|
||||
IsHovered = false;
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +386,12 @@ public class SkiaCheckBox : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Add some padding around the box for touch targets
|
||||
|
|
|
|||
|
|
@ -47,6 +47,21 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
private float _headerHeight = 0;
|
||||
private float _footerHeight = 0;
|
||||
|
||||
// Track if heights changed during draw (requires redraw for correct positioning)
|
||||
private bool _heightsChangedDuringDraw;
|
||||
|
||||
// Uses parent's _itemViewCache for virtualization
|
||||
|
||||
protected override void RefreshItems()
|
||||
{
|
||||
// Clear selection when items change to avoid stale references
|
||||
_selectedItems.Clear();
|
||||
_selectedItem = null;
|
||||
_selectedIndex = -1;
|
||||
|
||||
base.RefreshItems();
|
||||
}
|
||||
|
||||
public SkiaSelectionMode SelectionMode
|
||||
{
|
||||
get => _selectionMode;
|
||||
|
|
@ -175,7 +190,7 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
}
|
||||
}
|
||||
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x40);
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x59); // 35% opacity
|
||||
public SKColor HeaderBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor FooterBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
|
||||
|
|
@ -261,14 +276,7 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
|
||||
protected override void DrawItem(SKCanvas canvas, object item, int index, SKRect bounds, SKPaint paint)
|
||||
{
|
||||
// Draw selection highlight
|
||||
bool isSelected = _selectedItems.Contains(item);
|
||||
if (isSelected)
|
||||
{
|
||||
paint.Color = SelectionColor;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
|
||||
// Draw separator (only for vertical list layout)
|
||||
if (_orientation == ItemsLayoutOrientation.Vertical && _spanCount == 1)
|
||||
|
|
@ -279,6 +287,70 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
canvas.DrawLine(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, paint);
|
||||
}
|
||||
|
||||
// Try to use ItemViewCreator for templated rendering (from DataTemplate)
|
||||
if (ItemViewCreator != null)
|
||||
{
|
||||
// Get or create cached view for this index
|
||||
if (!_itemViewCache.TryGetValue(index, out var itemView) || itemView == null)
|
||||
{
|
||||
itemView = ItemViewCreator(item);
|
||||
if (itemView != null)
|
||||
{
|
||||
itemView.Parent = this;
|
||||
_itemViewCache[index] = itemView;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemView != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Measure with large height to get natural size
|
||||
var availableSize = new SKSize(bounds.Width, float.MaxValue);
|
||||
var measuredSize = itemView.Measure(availableSize);
|
||||
|
||||
// Cap measured height - if item returns infinity/MaxValue, use ItemHeight as default
|
||||
// This happens with Star-sized Grids that have no natural height preference
|
||||
var rawHeight = measuredSize.Height;
|
||||
if (float.IsNaN(rawHeight) || float.IsInfinity(rawHeight) || rawHeight > 10000)
|
||||
{
|
||||
rawHeight = ItemHeight;
|
||||
}
|
||||
// Ensure minimum height
|
||||
var measuredHeight = Math.Max(rawHeight, ItemHeight);
|
||||
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
|
||||
{
|
||||
_itemHeights[index] = measuredHeight;
|
||||
_heightsChangedDuringDraw = true; // Flag for redraw with correct positions
|
||||
}
|
||||
|
||||
// Arrange with the actual measured height
|
||||
var actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
|
||||
itemView.Arrange(actualBounds);
|
||||
itemView.Draw(canvas);
|
||||
|
||||
// Draw selection highlight ON TOP of the item content (semi-transparent overlay)
|
||||
if (isSelected)
|
||||
{
|
||||
paint.Color = SelectionColor;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRoundRect(actualBounds, 12, 12, paint);
|
||||
}
|
||||
|
||||
// Draw checkmark for selected items in multiple selection mode
|
||||
if (isSelected && _selectionMode == SkiaSelectionMode.Multiple)
|
||||
{
|
||||
DrawCheckmark(canvas, new SKRect(actualBounds.Right - 32, actualBounds.MidY - 8, actualBounds.Right - 16, actualBounds.MidY + 8));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaCollectionView.DrawItem] EXCEPTION: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom renderer if provided
|
||||
if (ItemRenderer != null)
|
||||
{
|
||||
|
|
@ -286,7 +358,7 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
return;
|
||||
}
|
||||
|
||||
// Default rendering
|
||||
// Default rendering - fall back to ToString
|
||||
paint.Color = SKColors.Black;
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
|
||||
|
|
@ -333,7 +405,10 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
// Reset the heights-changed flag at the start of each draw
|
||||
_heightsChangedDuringDraw = false;
|
||||
|
||||
// Draw background if set
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
|
|
@ -381,40 +456,67 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
{
|
||||
DrawListItems(canvas, contentBounds);
|
||||
}
|
||||
|
||||
// If heights changed during this draw, schedule a redraw with correct positions
|
||||
// This will queue another frame to be drawn with the correct cached heights
|
||||
if (_heightsChangedDuringDraw)
|
||||
{
|
||||
_heightsChangedDuringDraw = false;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawListItems(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Standard list drawing (delegate to base implementation via manual drawing)
|
||||
// Standard list drawing with variable item heights
|
||||
canvas.Save();
|
||||
canvas.ClipRect(bounds);
|
||||
|
||||
using var paint = new SKPaint { IsAntialias = true };
|
||||
|
||||
var scrollOffset = GetScrollOffset();
|
||||
var firstVisible = Math.Max(0, (int)(scrollOffset / (ItemHeight + ItemSpacing)));
|
||||
var lastVisible = Math.Min(ItemCount - 1,
|
||||
(int)((scrollOffset + bounds.Height) / (ItemHeight + ItemSpacing)) + 1);
|
||||
|
||||
for (int i = firstVisible; i <= lastVisible; i++)
|
||||
// Find first visible item by walking through items
|
||||
int firstVisible = 0;
|
||||
float cumulativeOffset = 0;
|
||||
for (int i = 0; i < ItemCount; i++)
|
||||
{
|
||||
var itemY = bounds.Top + (i * (ItemHeight + ItemSpacing)) - scrollOffset;
|
||||
var itemRect = new SKRect(bounds.Left, itemY, bounds.Right - 8, itemY + ItemHeight);
|
||||
|
||||
if (itemRect.Bottom < bounds.Top || itemRect.Top > bounds.Bottom)
|
||||
continue;
|
||||
|
||||
var item = GetItemAt(i);
|
||||
if (item != null)
|
||||
var itemH = GetItemHeight(i);
|
||||
if (cumulativeOffset + itemH > scrollOffset)
|
||||
{
|
||||
DrawItem(canvas, item, i, itemRect, paint);
|
||||
firstVisible = i;
|
||||
break;
|
||||
}
|
||||
cumulativeOffset += itemH + ItemSpacing;
|
||||
}
|
||||
|
||||
// Draw visible items using variable heights
|
||||
float currentY = bounds.Top + GetItemOffset(firstVisible) - scrollOffset;
|
||||
for (int i = firstVisible; i < ItemCount; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - 8, currentY + itemH);
|
||||
|
||||
// Stop if we've passed the visible area
|
||||
if (itemRect.Top > bounds.Bottom)
|
||||
break;
|
||||
|
||||
if (itemRect.Bottom >= bounds.Top)
|
||||
{
|
||||
var item = GetItemAt(i);
|
||||
if (item != null)
|
||||
{
|
||||
DrawItem(canvas, item, i, itemRect, paint);
|
||||
}
|
||||
}
|
||||
|
||||
currentY += itemH + ItemSpacing;
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
|
||||
// Draw scrollbar
|
||||
var totalHeight = ItemCount * (ItemHeight + ItemSpacing) - ItemSpacing;
|
||||
var totalHeight = TotalContentHeight;
|
||||
if (totalHeight > bounds.Height)
|
||||
{
|
||||
DrawScrollBarInternal(canvas, bounds, scrollOffset, totalHeight);
|
||||
|
|
@ -480,35 +582,41 @@ public class SkiaCollectionView : SkiaItemsView
|
|||
|
||||
private void DrawScrollBarInternal(SKCanvas canvas, SKRect bounds, float scrollOffset, float totalHeight)
|
||||
{
|
||||
var scrollBarWidth = 8f;
|
||||
var scrollBarWidth = 6f;
|
||||
var scrollBarMargin = 2f;
|
||||
|
||||
// Draw scrollbar track (subtle)
|
||||
var trackRect = new SKRect(
|
||||
bounds.Right - scrollBarWidth,
|
||||
bounds.Top,
|
||||
bounds.Right,
|
||||
bounds.Bottom);
|
||||
bounds.Right - scrollBarWidth - scrollBarMargin,
|
||||
bounds.Top + scrollBarMargin,
|
||||
bounds.Right - scrollBarMargin,
|
||||
bounds.Bottom - scrollBarMargin);
|
||||
|
||||
using var trackPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(200, 200, 200, 64),
|
||||
Color = new SKColor(0, 0, 0, 20),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(trackRect, trackPaint);
|
||||
canvas.DrawRoundRect(new SKRoundRect(trackRect, 3), trackPaint);
|
||||
|
||||
// Calculate thumb position and size
|
||||
var maxOffset = Math.Max(0, totalHeight - bounds.Height);
|
||||
var viewportRatio = bounds.Height / totalHeight;
|
||||
var thumbHeight = Math.Max(20, bounds.Height * viewportRatio);
|
||||
var availableTrackHeight = trackRect.Height;
|
||||
var thumbHeight = Math.Max(30, availableTrackHeight * viewportRatio);
|
||||
var scrollRatio = maxOffset > 0 ? scrollOffset / maxOffset : 0;
|
||||
var thumbY = bounds.Top + (bounds.Height - thumbHeight) * scrollRatio;
|
||||
var thumbY = trackRect.Top + (availableTrackHeight - thumbHeight) * scrollRatio;
|
||||
|
||||
var thumbRect = new SKRect(
|
||||
bounds.Right - scrollBarWidth + 1,
|
||||
trackRect.Left,
|
||||
thumbY,
|
||||
bounds.Right - 1,
|
||||
trackRect.Right,
|
||||
thumbY + thumbHeight);
|
||||
|
||||
// Draw thumb with more visible color
|
||||
using var thumbPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(128, 128, 128, 128),
|
||||
Color = new SKColor(100, 100, 100, 180),
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Presents content within a ControlTemplate.
|
||||
/// This control acts as a placeholder that gets replaced with the actual content
|
||||
/// when the template is applied to a control.
|
||||
/// </summary>
|
||||
public class SkiaContentPresenter : SkiaView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty ContentProperty =
|
||||
BindableProperty.Create(nameof(Content), typeof(SkiaView), typeof(SkiaContentPresenter), null,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).OnContentChanged((SkiaView?)o, (SkiaView?)n));
|
||||
|
||||
public static readonly BindableProperty HorizontalContentAlignmentProperty =
|
||||
BindableProperty.Create(nameof(HorizontalContentAlignment), typeof(LayoutAlignment), typeof(SkiaContentPresenter), LayoutAlignment.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty VerticalContentAlignmentProperty =
|
||||
BindableProperty.Create(nameof(VerticalContentAlignment), typeof(LayoutAlignment), typeof(SkiaContentPresenter), LayoutAlignment.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(nameof(Padding), typeof(SKRect), typeof(SkiaContentPresenter), SKRect.Empty,
|
||||
propertyChanged: (b, o, n) => ((SkiaContentPresenter)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content to present.
|
||||
/// </summary>
|
||||
public SkiaView? Content
|
||||
{
|
||||
get => (SkiaView?)GetValue(ContentProperty);
|
||||
set => SetValue(ContentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal alignment of the content.
|
||||
/// </summary>
|
||||
public LayoutAlignment HorizontalContentAlignment
|
||||
{
|
||||
get => (LayoutAlignment)GetValue(HorizontalContentAlignmentProperty);
|
||||
set => SetValue(HorizontalContentAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical alignment of the content.
|
||||
/// </summary>
|
||||
public LayoutAlignment VerticalContentAlignment
|
||||
{
|
||||
get => (LayoutAlignment)GetValue(VerticalContentAlignmentProperty);
|
||||
set => SetValue(VerticalContentAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding around the content.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void OnContentChanged(SkiaView? oldContent, SkiaView? newContent)
|
||||
{
|
||||
if (oldContent != null)
|
||||
{
|
||||
oldContent.Parent = null;
|
||||
}
|
||||
|
||||
if (newContent != null)
|
||||
{
|
||||
newContent.Parent = this;
|
||||
|
||||
// Propagate binding context to new content
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(newContent, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to content.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to content
|
||||
if (Content != null)
|
||||
{
|
||||
SetInheritedBindingContext(Content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background if set
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = BackgroundColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
}
|
||||
|
||||
// Draw content
|
||||
Content?.Draw(canvas);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var padding = Padding;
|
||||
|
||||
if (Content == null)
|
||||
return new SKSize(padding.Left + padding.Right, padding.Top + padding.Bottom);
|
||||
|
||||
// When alignment is not Fill, give content unlimited size in that dimension
|
||||
// so it can measure its natural size without truncation
|
||||
var measureWidth = HorizontalContentAlignment == LayoutAlignment.Fill
|
||||
? Math.Max(0, availableSize.Width - padding.Left - padding.Right)
|
||||
: float.PositiveInfinity;
|
||||
var measureHeight = VerticalContentAlignment == LayoutAlignment.Fill
|
||||
? Math.Max(0, availableSize.Height - padding.Top - padding.Bottom)
|
||||
: float.PositiveInfinity;
|
||||
|
||||
var contentSize = Content.Measure(new SKSize(measureWidth, measureHeight));
|
||||
return new SKSize(
|
||||
contentSize.Width + padding.Left + padding.Right,
|
||||
contentSize.Height + padding.Top + padding.Bottom);
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
if (Content != null)
|
||||
{
|
||||
var padding = Padding;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left + padding.Left,
|
||||
bounds.Top + padding.Top,
|
||||
bounds.Right - padding.Right,
|
||||
bounds.Bottom - padding.Bottom);
|
||||
|
||||
// Apply alignment
|
||||
var contentSize = Content.DesiredSize;
|
||||
var arrangedBounds = ApplyAlignment(contentBounds, contentSize, HorizontalContentAlignment, VerticalContentAlignment);
|
||||
Content.Arrange(arrangedBounds);
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static SKRect ApplyAlignment(SKRect availableBounds, SKSize contentSize, LayoutAlignment horizontal, LayoutAlignment vertical)
|
||||
{
|
||||
float x = availableBounds.Left;
|
||||
float y = availableBounds.Top;
|
||||
float width = horizontal == LayoutAlignment.Fill ? availableBounds.Width : contentSize.Width;
|
||||
float height = vertical == LayoutAlignment.Fill ? availableBounds.Height : contentSize.Height;
|
||||
|
||||
// Horizontal alignment
|
||||
switch (horizontal)
|
||||
{
|
||||
case LayoutAlignment.Center:
|
||||
x = availableBounds.Left + (availableBounds.Width - width) / 2;
|
||||
break;
|
||||
case LayoutAlignment.End:
|
||||
x = availableBounds.Right - width;
|
||||
break;
|
||||
}
|
||||
|
||||
// Vertical alignment
|
||||
switch (vertical)
|
||||
{
|
||||
case LayoutAlignment.Center:
|
||||
y = availableBounds.Top + (availableBounds.Height - height) / 2;
|
||||
break;
|
||||
case LayoutAlignment.End:
|
||||
y = availableBounds.Bottom - height;
|
||||
break;
|
||||
}
|
||||
|
||||
return new SKRect(x, y, x + width, y + height);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(x, y))
|
||||
return null;
|
||||
|
||||
// Check content first
|
||||
if (Content != null)
|
||||
{
|
||||
var hit = Content.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerPressed(e);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerMoved(e);
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
Content?.OnPointerReleased(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layout alignment options.
|
||||
/// </summary>
|
||||
public enum LayoutAlignment
|
||||
{
|
||||
/// <summary>
|
||||
/// Fill the available space.
|
||||
/// </summary>
|
||||
Fill,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the start (left or top).
|
||||
/// </summary>
|
||||
Start,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the center.
|
||||
/// </summary>
|
||||
Center,
|
||||
|
||||
/// <summary>
|
||||
/// Align to the end (right or bottom).
|
||||
/// </summary>
|
||||
End
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
|
|
@ -10,97 +11,234 @@ namespace Microsoft.Maui.Platform;
|
|||
/// </summary>
|
||||
public class SkiaDatePicker : SkiaView
|
||||
{
|
||||
private DateTime _date = DateTime.Today;
|
||||
private DateTime _minimumDate = new DateTime(1900, 1, 1);
|
||||
private DateTime _maximumDate = new DateTime(2100, 12, 31);
|
||||
private DateTime _displayMonth;
|
||||
private bool _isOpen;
|
||||
private string _format = "d";
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor CalendarBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor SelectedDayColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor TodayColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x40);
|
||||
public SKColor HeaderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledDayColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public static readonly BindableProperty DateProperty =
|
||||
BindableProperty.Create(nameof(Date), typeof(DateTime), typeof(SkiaDatePicker), DateTime.Today, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).OnDatePropertyChanged());
|
||||
|
||||
private const float CalendarWidth = 280;
|
||||
private const float CalendarHeight = 320;
|
||||
private const float DayCellSize = 36;
|
||||
private const float HeaderHeight = 48;
|
||||
public static readonly BindableProperty MinimumDateProperty =
|
||||
BindableProperty.Create(nameof(MinimumDate), typeof(DateTime), typeof(SkiaDatePicker), new DateTime(1900, 1, 1),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty MaximumDateProperty =
|
||||
BindableProperty.Create(nameof(MaximumDate), typeof(DateTime), typeof(SkiaDatePicker), new DateTime(2100, 12, 31),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FormatProperty =
|
||||
BindableProperty.Create(nameof(Format), typeof(string), typeof(SkiaDatePicker), "d",
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaDatePicker), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CalendarBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(CalendarBackgroundColor), typeof(SKColor), typeof(SkiaDatePicker), SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SelectedDayColorProperty =
|
||||
BindableProperty.Create(nameof(SelectedDayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TodayColorProperty =
|
||||
BindableProperty.Create(nameof(TodayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3, 0x40),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty HeaderColorProperty =
|
||||
BindableProperty.Create(nameof(HeaderColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty DisabledDayColorProperty =
|
||||
BindableProperty.Create(nameof(DisabledDayColor), typeof(SKColor), typeof(SkiaDatePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaDatePicker), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaDatePicker), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaDatePicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public DateTime Date
|
||||
{
|
||||
get => _date;
|
||||
set
|
||||
{
|
||||
var clamped = ClampDate(value);
|
||||
if (_date != clamped)
|
||||
{
|
||||
_date = clamped;
|
||||
_displayMonth = new DateTime(_date.Year, _date.Month, 1);
|
||||
DateSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (DateTime)GetValue(DateProperty);
|
||||
set => SetValue(DateProperty, ClampDate(value));
|
||||
}
|
||||
|
||||
public DateTime MinimumDate
|
||||
{
|
||||
get => _minimumDate;
|
||||
set { _minimumDate = value; Invalidate(); }
|
||||
get => (DateTime)GetValue(MinimumDateProperty);
|
||||
set => SetValue(MinimumDateProperty, value);
|
||||
}
|
||||
|
||||
public DateTime MaximumDate
|
||||
{
|
||||
get => _maximumDate;
|
||||
set { _maximumDate = value; Invalidate(); }
|
||||
get => (DateTime)GetValue(MaximumDateProperty);
|
||||
set => SetValue(MaximumDateProperty, value);
|
||||
}
|
||||
|
||||
public string Format
|
||||
{
|
||||
get => _format;
|
||||
set { _format = value; Invalidate(); }
|
||||
get => (string)GetValue(FormatProperty);
|
||||
set => SetValue(FormatProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor CalendarBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(CalendarBackgroundColorProperty);
|
||||
set => SetValue(CalendarBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SelectedDayColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedDayColorProperty);
|
||||
set => SetValue(SelectedDayColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TodayColor
|
||||
{
|
||||
get => (SKColor)GetValue(TodayColorProperty);
|
||||
set => SetValue(TodayColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor HeaderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HeaderColorProperty);
|
||||
set => SetValue(HeaderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor DisabledDayColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledDayColorProperty);
|
||||
set => SetValue(DisabledDayColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set { _isOpen = value; Invalidate(); }
|
||||
set
|
||||
{
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
RegisterPopupOverlay(this, DrawCalendarOverlay);
|
||||
else
|
||||
UnregisterPopupOverlay(this);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private DateTime _displayMonth;
|
||||
private bool _isOpen;
|
||||
|
||||
private const float CalendarWidth = 280;
|
||||
private const float CalendarHeight = 320;
|
||||
private const float HeaderHeight = 48;
|
||||
|
||||
public event EventHandler? DateSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the calendar popup rectangle with edge detection applied.
|
||||
/// </summary>
|
||||
private SKRect GetCalendarRect(SKRect pickerBounds)
|
||||
{
|
||||
// Get window dimensions for edge detection
|
||||
var windowWidth = LinuxApplication.Current?.MainWindow?.Width ?? 800;
|
||||
var windowHeight = LinuxApplication.Current?.MainWindow?.Height ?? 600;
|
||||
|
||||
// Calculate default position (below the picker)
|
||||
var calendarLeft = pickerBounds.Left;
|
||||
var calendarTop = pickerBounds.Bottom + 4;
|
||||
|
||||
// Edge detection: adjust horizontal position if popup would go off-screen
|
||||
if (calendarLeft + CalendarWidth > windowWidth)
|
||||
{
|
||||
calendarLeft = windowWidth - CalendarWidth - 4;
|
||||
}
|
||||
if (calendarLeft < 0) calendarLeft = 4;
|
||||
|
||||
// Edge detection: show above if popup would go off-screen vertically
|
||||
if (calendarTop + CalendarHeight > windowHeight)
|
||||
{
|
||||
calendarTop = pickerBounds.Top - CalendarHeight - 4;
|
||||
}
|
||||
if (calendarTop < 0) calendarTop = 4;
|
||||
|
||||
return new SKRect(calendarLeft, calendarTop, calendarLeft + CalendarWidth, calendarTop + CalendarHeight);
|
||||
}
|
||||
|
||||
public SkiaDatePicker()
|
||||
{
|
||||
IsFocusable = true;
|
||||
_displayMonth = new DateTime(_date.Year, _date.Month, 1);
|
||||
_displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
}
|
||||
|
||||
private void OnDatePropertyChanged()
|
||||
{
|
||||
_displayMonth = new DateTime(Date.Year, Date.Month, 1);
|
||||
DateSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private DateTime ClampDate(DateTime date)
|
||||
{
|
||||
if (date < _minimumDate) return _minimumDate;
|
||||
if (date > _maximumDate) return _maximumDate;
|
||||
if (date < MinimumDate) return MinimumDate;
|
||||
if (date > MaximumDate) return MaximumDate;
|
||||
return date;
|
||||
}
|
||||
|
||||
private void DrawCalendarOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
// Use ScreenBounds for popup drawing (accounts for scroll offset)
|
||||
DrawCalendar(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawCalendar(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5),
|
||||
|
|
@ -109,7 +247,6 @@ public class SkiaDatePicker : SkiaView
|
|||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = IsFocused ? SelectedDayColor : BorderColor,
|
||||
|
|
@ -119,7 +256,6 @@ public class SkiaDatePicker : SkiaView
|
|||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), borderPaint);
|
||||
|
||||
// Draw date text
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
|
|
@ -127,15 +263,11 @@ public class SkiaDatePicker : SkiaView
|
|||
IsAntialias = true
|
||||
};
|
||||
|
||||
var dateText = _date.ToString(_format);
|
||||
var dateText = Date.ToString(Format);
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(dateText, ref textBounds);
|
||||
canvas.DrawText(dateText, bounds.Left + 12, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
var textX = bounds.Left + 12;
|
||||
var textY = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(dateText, textX, textY, textPaint);
|
||||
|
||||
// Draw calendar icon
|
||||
DrawCalendarIcon(canvas, new SKRect(bounds.Right - 36, bounds.MidY - 10, bounds.Right - 12, bounds.MidY + 10));
|
||||
}
|
||||
|
||||
|
|
@ -149,40 +281,22 @@ public class SkiaDatePicker : SkiaView
|
|||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Calendar outline
|
||||
var calRect = new SKRect(bounds.Left, bounds.Top + 3, bounds.Right, bounds.Bottom);
|
||||
canvas.DrawRoundRect(new SKRoundRect(calRect, 2), paint);
|
||||
|
||||
// Top tabs
|
||||
canvas.DrawLine(bounds.Left + 5, bounds.Top, bounds.Left + 5, bounds.Top + 5, paint);
|
||||
canvas.DrawLine(bounds.Right - 5, bounds.Top, bounds.Right - 5, bounds.Top + 5, paint);
|
||||
|
||||
// Header line
|
||||
canvas.DrawLine(bounds.Left, bounds.Top + 8, bounds.Right, bounds.Top + 8, paint);
|
||||
|
||||
// Dots for days
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
paint.StrokeWidth = 0;
|
||||
for (int row = 0; row < 2; row++)
|
||||
{
|
||||
for (int col = 0; col < 3; col++)
|
||||
{
|
||||
var dotX = bounds.Left + 4 + col * 6;
|
||||
var dotY = bounds.Top + 12 + row * 4;
|
||||
canvas.DrawCircle(dotX, dotY, 1, paint);
|
||||
}
|
||||
}
|
||||
canvas.DrawCircle(bounds.Left + 4 + col * 6, bounds.Top + 12 + row * 4, 1, paint);
|
||||
}
|
||||
|
||||
private void DrawCalendar(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var calendarRect = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Bottom + 4,
|
||||
bounds.Left + CalendarWidth,
|
||||
bounds.Bottom + 4 + CalendarHeight);
|
||||
var calendarRect = GetCalendarRect(bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
|
|
@ -191,88 +305,44 @@ public class SkiaDatePicker : SkiaView
|
|||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(new SKRect(calendarRect.Left + 2, calendarRect.Top + 2, calendarRect.Right + 2, calendarRect.Bottom + 2), CornerRadius), shadowPaint);
|
||||
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = CalendarBackgroundColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var bgPaint = new SKPaint { Color = CalendarBackgroundColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(calendarRect, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var borderPaint = new SKPaint { Color = BorderColor, Style = SKPaintStyle.Stroke, StrokeWidth = 1, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(calendarRect, CornerRadius), borderPaint);
|
||||
|
||||
// Draw header
|
||||
DrawCalendarHeader(canvas, new SKRect(calendarRect.Left, calendarRect.Top, calendarRect.Right, calendarRect.Top + HeaderHeight));
|
||||
|
||||
// Draw weekday headers
|
||||
DrawWeekdayHeaders(canvas, new SKRect(calendarRect.Left, calendarRect.Top + HeaderHeight, calendarRect.Right, calendarRect.Top + HeaderHeight + 30));
|
||||
|
||||
// Draw days
|
||||
DrawDays(canvas, new SKRect(calendarRect.Left, calendarRect.Top + HeaderHeight + 30, calendarRect.Right, calendarRect.Bottom));
|
||||
}
|
||||
|
||||
private void DrawCalendarHeader(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw header background
|
||||
using var headerPaint = new SKPaint
|
||||
{
|
||||
Color = HeaderColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
var headerRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
using var headerPaint = new SKPaint { Color = HeaderColor, Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + CornerRadius * 2), CornerRadius));
|
||||
canvas.DrawRect(headerRect, headerPaint);
|
||||
canvas.DrawRect(bounds, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Top + CornerRadius, bounds.Right, bounds.Bottom), headerPaint);
|
||||
|
||||
// Draw month/year text
|
||||
using var font = new SKFont(SKTypeface.Default, 16);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
using var textPaint = new SKPaint(font) { Color = SKColors.White, IsAntialias = true };
|
||||
var monthYear = _displayMonth.ToString("MMMM yyyy");
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(monthYear, ref textBounds);
|
||||
canvas.DrawText(monthYear, bounds.MidX - textBounds.MidX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
// Draw navigation arrows
|
||||
using var arrowPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true,
|
||||
StrokeCap = SKStrokeCap.Round
|
||||
};
|
||||
|
||||
// Left arrow
|
||||
var leftArrowX = bounds.Left + 20;
|
||||
using var arrowPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true, StrokeCap = SKStrokeCap.Round };
|
||||
using var leftPath = new SKPath();
|
||||
leftPath.MoveTo(leftArrowX + 6, bounds.MidY - 6);
|
||||
leftPath.LineTo(leftArrowX, bounds.MidY);
|
||||
leftPath.LineTo(leftArrowX + 6, bounds.MidY + 6);
|
||||
leftPath.MoveTo(bounds.Left + 26, bounds.MidY - 6);
|
||||
leftPath.LineTo(bounds.Left + 20, bounds.MidY);
|
||||
leftPath.LineTo(bounds.Left + 26, bounds.MidY + 6);
|
||||
canvas.DrawPath(leftPath, arrowPaint);
|
||||
|
||||
// Right arrow
|
||||
var rightArrowX = bounds.Right - 20;
|
||||
using var rightPath = new SKPath();
|
||||
rightPath.MoveTo(rightArrowX - 6, bounds.MidY - 6);
|
||||
rightPath.LineTo(rightArrowX, bounds.MidY);
|
||||
rightPath.LineTo(rightArrowX - 6, bounds.MidY + 6);
|
||||
rightPath.MoveTo(bounds.Right - 26, bounds.MidY - 6);
|
||||
rightPath.LineTo(bounds.Right - 20, bounds.MidY);
|
||||
rightPath.LineTo(bounds.Right - 26, bounds.MidY + 6);
|
||||
canvas.DrawPath(rightPath, arrowPaint);
|
||||
}
|
||||
|
||||
|
|
@ -280,21 +350,13 @@ public class SkiaDatePicker : SkiaView
|
|||
{
|
||||
var dayNames = new[] { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
|
||||
var cellWidth = bounds.Width / 7;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 12);
|
||||
using var paint = new SKPaint(font)
|
||||
{
|
||||
Color = new SKColor(0x80, 0x80, 0x80),
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
using var paint = new SKPaint(font) { Color = new SKColor(0x80, 0x80, 0x80), IsAntialias = true };
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(dayNames[i], ref textBounds);
|
||||
var x = bounds.Left + i * cellWidth + cellWidth / 2 - textBounds.MidX;
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(dayNames[i], x, y, paint);
|
||||
canvas.DrawText(dayNames[i], bounds.Left + i * cellWidth + cellWidth / 2 - textBounds.MidX, bounds.MidY - textBounds.MidY, paint);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -303,14 +365,11 @@ public class SkiaDatePicker : SkiaView
|
|||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
|
||||
var cellWidth = bounds.Width / 7;
|
||||
var cellHeight = (bounds.Height - 10) / 6;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font) { IsAntialias = true };
|
||||
using var bgPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
|
||||
var today = DateTime.Today;
|
||||
|
||||
for (int day = 1; day <= daysInMonth; day++)
|
||||
|
|
@ -319,16 +378,12 @@ public class SkiaDatePicker : SkiaView
|
|||
var cellIndex = startDayOfWeek + day - 1;
|
||||
var row = cellIndex / 7;
|
||||
var col = cellIndex % 7;
|
||||
var cellRect = new SKRect(bounds.Left + col * cellWidth + 2, bounds.Top + row * cellHeight + 2, bounds.Left + (col + 1) * cellWidth - 2, bounds.Top + (row + 1) * cellHeight - 2);
|
||||
|
||||
var cellX = bounds.Left + col * cellWidth;
|
||||
var cellY = bounds.Top + row * cellHeight;
|
||||
var cellRect = new SKRect(cellX + 2, cellY + 2, cellX + cellWidth - 2, cellY + cellHeight - 2);
|
||||
|
||||
var isSelected = dayDate.Date == _date.Date;
|
||||
var isSelected = dayDate.Date == Date.Date;
|
||||
var isToday = dayDate.Date == today;
|
||||
var isDisabled = dayDate < _minimumDate || dayDate > _maximumDate;
|
||||
var isDisabled = dayDate < MinimumDate || dayDate > MaximumDate;
|
||||
|
||||
// Draw day background
|
||||
if (isSelected)
|
||||
{
|
||||
bgPaint.Color = SelectedDayColor;
|
||||
|
|
@ -340,7 +395,6 @@ public class SkiaDatePicker : SkiaView
|
|||
canvas.DrawCircle(cellRect.MidX, cellRect.MidY, Math.Min(cellRect.Width, cellRect.Height) / 2 - 2, bgPaint);
|
||||
}
|
||||
|
||||
// Draw day text
|
||||
textPaint.Color = isSelected ? SKColors.White : isDisabled ? DisabledDayColor : TextColor;
|
||||
var dayText = day.ToString();
|
||||
var textBounds = new SKRect();
|
||||
|
|
@ -353,115 +407,104 @@ public class SkiaDatePicker : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
var calendarTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var calendarRect = GetCalendarRect(screenBounds);
|
||||
|
||||
// Check header navigation
|
||||
if (e.Y >= calendarTop && e.Y < calendarTop + HeaderHeight)
|
||||
// Check if click is in header area (navigation arrows)
|
||||
var headerRect = new SKRect(calendarRect.Left, calendarRect.Top, calendarRect.Right, calendarRect.Top + HeaderHeight);
|
||||
if (headerRect.Contains(e.X, e.Y))
|
||||
{
|
||||
if (e.X < Bounds.Left + 40)
|
||||
{
|
||||
// Previous month
|
||||
_displayMonth = _displayMonth.AddMonths(-1);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
else if (e.X > Bounds.Left + CalendarWidth - 40)
|
||||
{
|
||||
// Next month
|
||||
_displayMonth = _displayMonth.AddMonths(1);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
if (e.X < calendarRect.Left + 40) { _displayMonth = _displayMonth.AddMonths(-1); Invalidate(); return; }
|
||||
if (e.X > calendarRect.Right - 40) { _displayMonth = _displayMonth.AddMonths(1); Invalidate(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Check day selection
|
||||
var daysTop = calendarTop + HeaderHeight + 30;
|
||||
if (e.Y >= daysTop && e.Y < calendarTop + CalendarHeight)
|
||||
// Check if click is in days area
|
||||
var daysTop = calendarRect.Top + HeaderHeight + 30;
|
||||
var daysRect = new SKRect(calendarRect.Left, daysTop, calendarRect.Right, calendarRect.Bottom);
|
||||
if (daysRect.Contains(e.X, e.Y))
|
||||
{
|
||||
var cellWidth = CalendarWidth / 7;
|
||||
var cellHeight = (CalendarHeight - HeaderHeight - 40) / 6;
|
||||
|
||||
var col = (int)((e.X - Bounds.Left) / cellWidth);
|
||||
var col = (int)((e.X - calendarRect.Left) / cellWidth);
|
||||
var row = (int)((e.Y - daysTop) / cellHeight);
|
||||
|
||||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
var dayIndex = row * 7 + col - startDayOfWeek + 1;
|
||||
var dayIndex = row * 7 + col - (int)firstDay.DayOfWeek + 1;
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
|
||||
if (dayIndex >= 1 && dayIndex <= daysInMonth)
|
||||
{
|
||||
var selectedDate = new DateTime(_displayMonth.Year, _displayMonth.Month, dayIndex);
|
||||
if (selectedDate >= _minimumDate && selectedDate <= _maximumDate)
|
||||
if (selectedDate >= MinimumDate && selectedDate <= MaximumDate)
|
||||
{
|
||||
Date = selectedDate;
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
else if (e.Y < calendarTop)
|
||||
{
|
||||
_isOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
}
|
||||
|
||||
// Click is outside calendar - check if it's on the picker itself
|
||||
if (screenBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
else IsOpen = true;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
_isOpen = !_isOpen;
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Left:
|
||||
Date = _date.AddDays(-1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Right:
|
||||
Date = _date.AddDays(1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Date = _date.AddDays(-7);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Date = _date.AddDays(7);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter: case Key.Space: IsOpen = !IsOpen; e.Handled = true; break;
|
||||
case Key.Escape: if (IsOpen) { IsOpen = false; e.Handled = true; } break;
|
||||
case Key.Left: Date = Date.AddDays(-1); e.Handled = true; break;
|
||||
case Key.Right: Date = Date.AddDays(1); e.Handled = true; break;
|
||||
case Key.Up: Date = Date.AddDays(-7); e.Handled = true; break;
|
||||
case Key.Down: Date = Date.AddDays(7); e.Handled = true; break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
// Close popup when focus is lost (clicking outside)
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
return new SKSize(availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200, 40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include calendar popup area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the calendar area (with edge detection)
|
||||
if (_isOpen)
|
||||
{
|
||||
var calendarRect = GetCalendarRect(screenBounds);
|
||||
return calendarRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,90 +6,354 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered multiline text editor control.
|
||||
/// Skia-rendered multiline text editor control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaEditor : SkiaView
|
||||
{
|
||||
private string _text = "";
|
||||
private string _placeholder = "";
|
||||
private int _cursorPosition;
|
||||
private int _selectionStart = -1;
|
||||
private int _selectionLength;
|
||||
private float _scrollOffsetY;
|
||||
private bool _isReadOnly;
|
||||
private int _maxLength = -1;
|
||||
private bool _cursorVisible = true;
|
||||
private DateTime _lastCursorBlink = DateTime.Now;
|
||||
#region BindableProperties
|
||||
|
||||
// Cached line information
|
||||
private List<string> _lines = new() { "" };
|
||||
private List<float> _lineHeights = new();
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"",
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).OnTextPropertyChanged((string)o, (string)n));
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor PlaceholderColor { get; set; } = new SKColor(0x80, 0x80, 0x80);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x60);
|
||||
public SKColor CursorColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float LineHeight { get; set; } = 1.4f;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float Padding { get; set; } = 12;
|
||||
public bool AutoSize { get; set; }
|
||||
/// <summary>
|
||||
/// Bindable property for Placeholder.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PlaceholderProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Placeholder),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for PlaceholderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PlaceholderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(PlaceholderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x80, 0x80, 0x80),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for SelectionColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectionColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectionColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x21, 0x96, 0xF3, 0x60),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CursorColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CursorColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CursorColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaEditor),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaEditor),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
1.4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(float),
|
||||
typeof(SkiaEditor),
|
||||
12f,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsReadOnly.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsReadOnlyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsReadOnly),
|
||||
typeof(bool),
|
||||
typeof(SkiaEditor),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MaxLength.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaxLengthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MaxLength),
|
||||
typeof(int),
|
||||
typeof(SkiaEditor),
|
||||
-1);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for AutoSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty AutoSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(AutoSize),
|
||||
typeof(bool),
|
||||
typeof(SkiaEditor),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text content.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
var newText = value ?? "";
|
||||
if (_maxLength > 0 && newText.Length > _maxLength)
|
||||
{
|
||||
newText = newText.Substring(0, _maxLength);
|
||||
}
|
||||
|
||||
if (_text != newText)
|
||||
{
|
||||
_text = newText;
|
||||
UpdateLines();
|
||||
_cursorPosition = Math.Min(_cursorPosition, _text.Length);
|
||||
TextChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder text.
|
||||
/// </summary>
|
||||
public string Placeholder
|
||||
{
|
||||
get => _placeholder;
|
||||
set { _placeholder = value ?? ""; Invalidate(); }
|
||||
get => (string)GetValue(PlaceholderProperty);
|
||||
set => SetValue(PlaceholderProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder color.
|
||||
/// </summary>
|
||||
public SKColor PlaceholderColor
|
||||
{
|
||||
get => (SKColor)GetValue(PlaceholderColorProperty);
|
||||
set => SetValue(PlaceholderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selection color.
|
||||
/// </summary>
|
||||
public SKColor SelectionColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectionColorProperty);
|
||||
set => SetValue(SelectionColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor color.
|
||||
/// </summary>
|
||||
public SKColor CursorColor
|
||||
{
|
||||
get => (SKColor)GetValue(CursorColorProperty);
|
||||
set => SetValue(CursorColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line height multiplier.
|
||||
/// </summary>
|
||||
public float LineHeight
|
||||
{
|
||||
get => (float)GetValue(LineHeightProperty);
|
||||
set => SetValue(LineHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public float Padding
|
||||
{
|
||||
get => (float)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the editor is read-only.
|
||||
/// </summary>
|
||||
public bool IsReadOnly
|
||||
{
|
||||
get => _isReadOnly;
|
||||
set { _isReadOnly = value; Invalidate(); }
|
||||
get => (bool)GetValue(IsReadOnlyProperty);
|
||||
set => SetValue(IsReadOnlyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum length. -1 for unlimited.
|
||||
/// </summary>
|
||||
public int MaxLength
|
||||
{
|
||||
get => _maxLength;
|
||||
set { _maxLength = value; }
|
||||
get => (int)GetValue(MaxLengthProperty);
|
||||
set => SetValue(MaxLengthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the editor auto-sizes to content.
|
||||
/// </summary>
|
||||
public bool AutoSize
|
||||
{
|
||||
get => (bool)GetValue(AutoSizeProperty);
|
||||
set => SetValue(AutoSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor position.
|
||||
/// </summary>
|
||||
public int CursorPosition
|
||||
{
|
||||
get => _cursorPosition;
|
||||
set
|
||||
{
|
||||
_cursorPosition = Math.Clamp(value, 0, _text.Length);
|
||||
_cursorPosition = Math.Clamp(value, 0, Text.Length);
|
||||
EnsureCursorVisible();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private int _cursorPosition;
|
||||
private int _selectionStart = -1;
|
||||
private int _selectionLength;
|
||||
private float _scrollOffsetY;
|
||||
private bool _cursorVisible = true;
|
||||
private DateTime _lastCursorBlink = DateTime.Now;
|
||||
private List<string> _lines = new() { "" };
|
||||
private float _wrapWidth = 0; // Available width for word wrapping
|
||||
private bool _isSelecting; // For mouse-based text selection
|
||||
private DateTime _lastClickTime = DateTime.MinValue;
|
||||
private float _lastClickX;
|
||||
private float _lastClickY;
|
||||
private const double DoubleClickThresholdMs = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when text changes.
|
||||
/// </summary>
|
||||
public event EventHandler? TextChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when editing is completed.
|
||||
/// </summary>
|
||||
public event EventHandler? Completed;
|
||||
|
||||
public SkiaEditor()
|
||||
|
|
@ -97,29 +361,92 @@ public class SkiaEditor : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnTextPropertyChanged(string oldText, string newText)
|
||||
{
|
||||
var text = newText ?? "";
|
||||
|
||||
if (MaxLength > 0 && text.Length > MaxLength)
|
||||
{
|
||||
text = text.Substring(0, MaxLength);
|
||||
SetValue(TextProperty, text);
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateLines();
|
||||
_cursorPosition = Math.Min(_cursorPosition, text.Length);
|
||||
_scrollOffsetY = 0; // Reset scroll when text changes externally
|
||||
_selectionLength = 0;
|
||||
TextChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void UpdateLines()
|
||||
{
|
||||
_lines.Clear();
|
||||
if (string.IsNullOrEmpty(_text))
|
||||
var text = Text ?? "";
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
_lines.Add("");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentLine = "";
|
||||
foreach (var ch in _text)
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
|
||||
// Split by actual newlines first
|
||||
var paragraphs = text.Split('\n');
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
if (ch == '\n')
|
||||
if (string.IsNullOrEmpty(paragraph))
|
||||
{
|
||||
_lines.Add(currentLine);
|
||||
currentLine = "";
|
||||
_lines.Add("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word wrap this paragraph if we have a known width
|
||||
if (_wrapWidth > 0)
|
||||
{
|
||||
WrapParagraph(paragraph, font, _wrapWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine += ch;
|
||||
_lines.Add(paragraph);
|
||||
}
|
||||
}
|
||||
_lines.Add(currentLine);
|
||||
|
||||
if (_lines.Count == 0)
|
||||
{
|
||||
_lines.Add("");
|
||||
}
|
||||
}
|
||||
|
||||
private void WrapParagraph(string paragraph, SKFont font, float maxWidth)
|
||||
{
|
||||
var words = paragraph.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var lineWidth = MeasureText(testLine, font);
|
||||
|
||||
if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
// Line too long, save current and start new
|
||||
_lines.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
_lines.Add(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
private (int line, int column) GetLineColumn(int position)
|
||||
|
|
@ -132,7 +459,7 @@ public class SkiaEditor : SkiaView
|
|||
{
|
||||
return (i, position - pos);
|
||||
}
|
||||
pos += lineLength + 1; // +1 for newline
|
||||
pos += lineLength + 1;
|
||||
}
|
||||
return (_lines.Count - 1, _lines[^1].Length);
|
||||
}
|
||||
|
|
@ -148,11 +475,19 @@ public class SkiaEditor : SkiaView
|
|||
{
|
||||
pos += Math.Min(column, _lines[line].Length);
|
||||
}
|
||||
return Math.Min(pos, _text.Length);
|
||||
return Math.Min(pos, Text.Length);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Update wrap width if bounds changed and re-wrap text
|
||||
var newWrapWidth = bounds.Width - Padding * 2;
|
||||
if (Math.Abs(newWrapWidth - _wrapWidth) > 1)
|
||||
{
|
||||
_wrapWidth = newWrapWidth;
|
||||
UpdateLines();
|
||||
}
|
||||
|
||||
// Handle cursor blinking
|
||||
if (IsFocused && (DateTime.Now - _lastCursorBlink).TotalMilliseconds > 500)
|
||||
{
|
||||
|
|
@ -192,21 +527,20 @@ public class SkiaEditor : SkiaView
|
|||
|
||||
canvas.Save();
|
||||
canvas.ClipRect(contentRect);
|
||||
canvas.Translate(0, -_scrollOffsetY);
|
||||
// Don't translate - let the text draw at absolute positions
|
||||
// canvas.Translate(0, -_scrollOffsetY);
|
||||
|
||||
if (string.IsNullOrEmpty(_text) && !string.IsNullOrEmpty(_placeholder))
|
||||
if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder))
|
||||
{
|
||||
// Draw placeholder
|
||||
using var placeholderPaint = new SKPaint(font)
|
||||
{
|
||||
Color = PlaceholderColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawText(_placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint);
|
||||
canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Draw text with selection
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
|
|
@ -227,15 +561,17 @@ public class SkiaEditor : SkiaView
|
|||
var x = contentRect.Left;
|
||||
|
||||
// Draw selection for this line if applicable
|
||||
if (_selectionStart >= 0 && _selectionLength > 0)
|
||||
if (_selectionStart >= 0 && _selectionLength != 0)
|
||||
{
|
||||
var selEnd = _selectionStart + _selectionLength;
|
||||
// Handle both positive and negative selection lengths
|
||||
var selStart = _selectionLength > 0 ? _selectionStart : _selectionStart + _selectionLength;
|
||||
var selEnd = _selectionLength > 0 ? _selectionStart + _selectionLength : _selectionStart;
|
||||
var lineStart = charIndex;
|
||||
var lineEnd = charIndex + line.Length;
|
||||
|
||||
if (selEnd > lineStart && _selectionStart < lineEnd)
|
||||
if (selEnd > lineStart && selStart < lineEnd)
|
||||
{
|
||||
var selStartInLine = Math.Max(0, _selectionStart - lineStart);
|
||||
var selStartInLine = Math.Max(0, selStart - lineStart);
|
||||
var selEndInLine = Math.Min(line.Length, selEnd - lineStart);
|
||||
|
||||
var startX = x + MeasureText(line.Substring(0, selStartInLine), font);
|
||||
|
|
@ -245,7 +581,6 @@ public class SkiaEditor : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
// Draw line text
|
||||
canvas.DrawText(line, x, y, textPaint);
|
||||
|
||||
// Draw cursor if on this line
|
||||
|
|
@ -267,7 +602,7 @@ public class SkiaEditor : SkiaView
|
|||
}
|
||||
|
||||
y += lineSpacing;
|
||||
charIndex += line.Length + 1; // +1 for newline
|
||||
charIndex += line.Length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -332,12 +667,12 @@ public class SkiaEditor : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
// Request focus by notifying parent
|
||||
IsFocused = true;
|
||||
|
||||
// Calculate cursor position from click
|
||||
var contentX = e.X - Bounds.Left - Padding;
|
||||
var contentY = e.Y - Bounds.Top - Padding + _scrollOffsetY;
|
||||
// Use screen coordinates for proper hit detection
|
||||
var screenBounds = ScreenBounds;
|
||||
var contentX = e.X - screenBounds.Left - Padding;
|
||||
var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY;
|
||||
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
|
||||
|
|
@ -346,7 +681,6 @@ public class SkiaEditor : SkiaView
|
|||
var line = _lines[clickedLine];
|
||||
var clickedCol = 0;
|
||||
|
||||
// Find closest character position
|
||||
for (int i = 0; i <= line.Length; i++)
|
||||
{
|
||||
var charX = MeasureText(line.Substring(0, i), font);
|
||||
|
|
@ -359,14 +693,79 @@ public class SkiaEditor : SkiaView
|
|||
}
|
||||
|
||||
_cursorPosition = GetPosition(clickedLine, clickedCol);
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
|
||||
// Check for double-click (select word)
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
|
||||
var distanceFromLastClick = Math.Sqrt(Math.Pow(e.X - _lastClickX, 2) + Math.Pow(e.Y - _lastClickY, 2));
|
||||
|
||||
if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
|
||||
{
|
||||
// Double-click: select the word at cursor
|
||||
SelectWordAtCursor();
|
||||
_lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
|
||||
_isSelecting = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single click: start selection
|
||||
_selectionStart = _cursorPosition;
|
||||
_selectionLength = 0;
|
||||
_isSelecting = true;
|
||||
_lastClickTime = now;
|
||||
_lastClickX = e.X;
|
||||
_lastClickY = e.Y;
|
||||
}
|
||||
|
||||
_cursorVisible = true;
|
||||
_lastCursorBlink = DateTime.Now;
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled || !_isSelecting) return;
|
||||
|
||||
// Calculate position from mouse coordinates
|
||||
var screenBounds = ScreenBounds;
|
||||
var contentX = e.X - screenBounds.Left - Padding;
|
||||
var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY;
|
||||
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
var line = _lines[clickedLine];
|
||||
var clickedCol = 0;
|
||||
|
||||
for (int i = 0; i <= line.Length; i++)
|
||||
{
|
||||
var charX = MeasureText(line.Substring(0, i), font);
|
||||
if (charX > contentX)
|
||||
{
|
||||
clickedCol = i > 0 ? i - 1 : 0;
|
||||
break;
|
||||
}
|
||||
clickedCol = i;
|
||||
}
|
||||
|
||||
var newPosition = GetPosition(clickedLine, clickedCol);
|
||||
if (newPosition != _cursorPosition)
|
||||
{
|
||||
_cursorPosition = newPosition;
|
||||
_selectionLength = _cursorPosition - _selectionStart;
|
||||
_cursorVisible = true;
|
||||
_lastCursorBlink = DateTime.Now;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
_isSelecting = false;
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
|
@ -387,7 +786,7 @@ public class SkiaEditor : SkiaView
|
|||
break;
|
||||
|
||||
case Key.Right:
|
||||
if (_cursorPosition < _text.Length)
|
||||
if (_cursorPosition < Text.Length)
|
||||
{
|
||||
_cursorPosition++;
|
||||
EnsureCursorVisible();
|
||||
|
|
@ -426,7 +825,7 @@ public class SkiaEditor : SkiaView
|
|||
break;
|
||||
|
||||
case Key.Enter:
|
||||
if (!_isReadOnly)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
InsertText("\n");
|
||||
}
|
||||
|
|
@ -434,30 +833,76 @@ public class SkiaEditor : SkiaView
|
|||
break;
|
||||
|
||||
case Key.Backspace:
|
||||
if (!_isReadOnly && _cursorPosition > 0)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
Text = _text.Remove(_cursorPosition - 1, 1);
|
||||
_cursorPosition--;
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
else if (_cursorPosition > 0)
|
||||
{
|
||||
Text = Text.Remove(_cursorPosition - 1, 1);
|
||||
_cursorPosition--;
|
||||
}
|
||||
EnsureCursorVisible();
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Delete:
|
||||
if (!_isReadOnly && _cursorPosition < _text.Length)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
Text = _text.Remove(_cursorPosition, 1);
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
else if (_cursorPosition < Text.Length)
|
||||
{
|
||||
Text = Text.Remove(_cursorPosition, 1);
|
||||
}
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Tab:
|
||||
if (!_isReadOnly)
|
||||
if (!IsReadOnly)
|
||||
{
|
||||
InsertText(" "); // 4 spaces for tab
|
||||
InsertText(" ");
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.A:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
SelectAll();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.C:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
CopyToClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.V:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
|
||||
{
|
||||
PasteFromClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.X:
|
||||
if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
|
||||
{
|
||||
CutToClipboard();
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
|
|
@ -465,7 +910,11 @@ public class SkiaEditor : SkiaView
|
|||
|
||||
public override void OnTextInput(TextInputEventArgs e)
|
||||
{
|
||||
if (!IsEnabled || _isReadOnly) return;
|
||||
if (!IsEnabled || IsReadOnly) return;
|
||||
|
||||
// Ignore control characters (Ctrl+key combinations send ASCII control codes)
|
||||
if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(e.Text))
|
||||
{
|
||||
|
|
@ -478,21 +927,21 @@ public class SkiaEditor : SkiaView
|
|||
{
|
||||
if (_selectionLength > 0)
|
||||
{
|
||||
// Replace selection
|
||||
_text = _text.Remove(_selectionStart, _selectionLength);
|
||||
var currentText = Text;
|
||||
Text = currentText.Remove(_selectionStart, _selectionLength);
|
||||
_cursorPosition = _selectionStart;
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
}
|
||||
|
||||
if (_maxLength > 0 && _text.Length + text.Length > _maxLength)
|
||||
if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
|
||||
{
|
||||
text = text.Substring(0, Math.Max(0, _maxLength - _text.Length));
|
||||
text = text.Substring(0, Math.Max(0, MaxLength - Text.Length));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
Text = _text.Insert(_cursorPosition, text);
|
||||
Text = Text.Insert(_cursorPosition, text);
|
||||
_cursorPosition += text.Length;
|
||||
EnsureCursorVisible();
|
||||
}
|
||||
|
|
@ -509,6 +958,102 @@ public class SkiaEditor : SkiaView
|
|||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusGained()
|
||||
{
|
||||
base.OnFocusGained();
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
|
||||
}
|
||||
|
||||
#region Selection and Clipboard
|
||||
|
||||
public void SelectAll()
|
||||
{
|
||||
_selectionStart = 0;
|
||||
_cursorPosition = Text.Length;
|
||||
_selectionLength = Text.Length;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void SelectWordAtCursor()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text)) return;
|
||||
|
||||
// Find word boundaries
|
||||
int start = _cursorPosition;
|
||||
int end = _cursorPosition;
|
||||
|
||||
// Move start backwards to beginning of word
|
||||
while (start > 0 && IsWordChar(Text[start - 1]))
|
||||
start--;
|
||||
|
||||
// Move end forwards to end of word
|
||||
while (end < Text.Length && IsWordChar(Text[end]))
|
||||
end++;
|
||||
|
||||
_selectionStart = start;
|
||||
_cursorPosition = end;
|
||||
_selectionLength = end - start;
|
||||
}
|
||||
|
||||
private static bool IsWordChar(char c)
|
||||
{
|
||||
return char.IsLetterOrDigit(c) || c == '_';
|
||||
}
|
||||
|
||||
private void CopyToClipboard()
|
||||
{
|
||||
if (_selectionLength == 0) return;
|
||||
|
||||
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
|
||||
var length = Math.Abs(_selectionLength);
|
||||
var selectedText = Text.Substring(start, length);
|
||||
|
||||
// Use system clipboard via xclip/xsel
|
||||
SystemClipboard.SetText(selectedText);
|
||||
}
|
||||
|
||||
private void CutToClipboard()
|
||||
{
|
||||
CopyToClipboard();
|
||||
DeleteSelection();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void PasteFromClipboard()
|
||||
{
|
||||
// Get from system clipboard
|
||||
var text = SystemClipboard.GetText();
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
|
||||
if (_selectionLength != 0)
|
||||
{
|
||||
DeleteSelection();
|
||||
}
|
||||
|
||||
InsertText(text);
|
||||
}
|
||||
|
||||
private void DeleteSelection()
|
||||
{
|
||||
if (_selectionLength == 0) return;
|
||||
|
||||
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
|
||||
var length = Math.Abs(_selectionLength);
|
||||
|
||||
Text = Text.Remove(start, length);
|
||||
_cursorPosition = start;
|
||||
_selectionStart = -1;
|
||||
_selectionLength = 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
if (AutoSize)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,8 +22,13 @@ public class SkiaItemsView : SkiaView
|
|||
private int _firstVisibleIndex;
|
||||
private int _lastVisibleIndex;
|
||||
private bool _isDragging;
|
||||
private bool _isDraggingScrollbar;
|
||||
private float _dragStartY;
|
||||
private float _dragStartOffset;
|
||||
private float _scrollbarDragStartY;
|
||||
private float _scrollbarDragStartScrollOffset;
|
||||
private float _scrollbarDragAvailableTrack;
|
||||
private float _scrollbarDragMaxScroll;
|
||||
private float _velocity;
|
||||
private DateTime _lastDragTime;
|
||||
|
||||
|
|
@ -81,9 +86,21 @@ public class SkiaItemsView : SkiaView
|
|||
public object? EmptyView { get; set; }
|
||||
public string? EmptyViewText { get; set; } = "No items";
|
||||
|
||||
// Item rendering delegate
|
||||
// Item rendering delegate (legacy)
|
||||
public Func<object, int, SKRect, SKCanvas, SKPaint, bool>? ItemRenderer { get; set; }
|
||||
|
||||
// Item view creator - creates SkiaView from data item using DataTemplate
|
||||
public Func<object, SkiaView?>? ItemViewCreator { get; set; }
|
||||
|
||||
// Cache of created item views for virtualization
|
||||
protected readonly Dictionary<int, SkiaView> _itemViewCache = new();
|
||||
|
||||
// Cache of individual item heights for variable height items
|
||||
protected readonly Dictionary<int, float> _itemHeights = new();
|
||||
|
||||
// Track last measured width to clear cache when width changes
|
||||
private float _lastMeasuredWidth = 0;
|
||||
|
||||
// Selection support (overridden in SkiaCollectionView)
|
||||
public virtual int SelectedIndex { get; set; } = -1;
|
||||
|
||||
|
|
@ -95,9 +112,12 @@ public class SkiaItemsView : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void RefreshItems()
|
||||
protected virtual void RefreshItems()
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] RefreshItems called, clearing {_items.Count} items and {_itemViewCache.Count} cached views");
|
||||
_items.Clear();
|
||||
_itemViewCache.Clear(); // Clear cached views when items change
|
||||
_itemHeights.Clear(); // Clear cached heights
|
||||
if (_itemsSource != null)
|
||||
{
|
||||
foreach (var item in _itemsSource)
|
||||
|
|
@ -105,6 +125,7 @@ public class SkiaItemsView : SkiaView
|
|||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[SkiaItemsView] RefreshItems done, now have {_items.Count} items");
|
||||
_scrollOffset = 0;
|
||||
}
|
||||
|
||||
|
|
@ -114,11 +135,53 @@ public class SkiaItemsView : SkiaView
|
|||
Invalidate();
|
||||
}
|
||||
|
||||
protected float TotalContentHeight => _items.Count * (_itemHeight + _itemSpacing) - _itemSpacing;
|
||||
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - Bounds.Height);
|
||||
/// <summary>
|
||||
/// Gets the height for a specific item, using cached height or default.
|
||||
/// </summary>
|
||||
protected float GetItemHeight(int index)
|
||||
{
|
||||
return _itemHeights.TryGetValue(index, out var height) ? height : _itemHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Y offset for a specific item (cumulative height of all previous items).
|
||||
/// </summary>
|
||||
protected float GetItemOffset(int index)
|
||||
{
|
||||
float offset = 0;
|
||||
for (int i = 0; i < index && i < _items.Count; i++)
|
||||
{
|
||||
offset += GetItemHeight(i) + _itemSpacing;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates total content height based on individual item heights.
|
||||
/// </summary>
|
||||
protected float TotalContentHeight
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_items.Count == 0) return 0;
|
||||
|
||||
float total = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
total += GetItemHeight(i);
|
||||
if (i < _items.Count - 1) total += _itemSpacing;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// Use ScreenBounds.Height for visible viewport
|
||||
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - ScreenBounds.Height);
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] OnDraw - bounds={bounds}, items={_items.Count}, ItemViewCreator={(ItemViewCreator != null ? "set" : "null")}");
|
||||
|
||||
// Draw background
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
|
|
@ -137,30 +200,51 @@ public class SkiaItemsView : SkiaView
|
|||
return;
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
_firstVisibleIndex = Math.Max(0, (int)(_scrollOffset / (_itemHeight + _itemSpacing)));
|
||||
_lastVisibleIndex = Math.Min(_items.Count - 1,
|
||||
(int)((_scrollOffset + bounds.Height) / (_itemHeight + _itemSpacing)) + 1);
|
||||
// Find first visible index by walking through items
|
||||
_firstVisibleIndex = 0;
|
||||
float cumulativeOffset = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
if (cumulativeOffset + itemH > _scrollOffset)
|
||||
{
|
||||
_firstVisibleIndex = i;
|
||||
break;
|
||||
}
|
||||
cumulativeOffset += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
// Clip to bounds
|
||||
canvas.Save();
|
||||
canvas.ClipRect(bounds);
|
||||
|
||||
// Draw visible items
|
||||
// Draw visible items using variable heights
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
for (int i = _firstVisibleIndex; i <= _lastVisibleIndex; i++)
|
||||
float currentY = bounds.Top + GetItemOffset(_firstVisibleIndex) - _scrollOffset;
|
||||
for (int i = _firstVisibleIndex; i < _items.Count; i++)
|
||||
{
|
||||
var itemY = bounds.Top + (i * (_itemHeight + _itemSpacing)) - _scrollOffset;
|
||||
var itemRect = new SKRect(bounds.Left, itemY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), itemY + _itemHeight);
|
||||
var itemH = GetItemHeight(i);
|
||||
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), currentY + itemH);
|
||||
|
||||
if (itemRect.Bottom < bounds.Top || itemRect.Top > bounds.Bottom)
|
||||
continue;
|
||||
// Stop if we've passed the visible area
|
||||
if (itemRect.Top > bounds.Bottom)
|
||||
{
|
||||
_lastVisibleIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
_lastVisibleIndex = i;
|
||||
|
||||
if (itemRect.Bottom >= bounds.Top)
|
||||
{
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
}
|
||||
|
||||
currentY += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
|
|
@ -177,11 +261,56 @@ public class SkiaItemsView : SkiaView
|
|||
// Draw selection highlight
|
||||
if (index == SelectedIndex)
|
||||
{
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x40); // Light blue
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x59); // Light blue with 35% opacity
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
|
||||
// Try to use ItemViewCreator for templated rendering
|
||||
if (ItemViewCreator != null)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] DrawItem {index} - ItemViewCreator exists, item: {item}");
|
||||
// Get or create cached view for this index
|
||||
if (!_itemViewCache.TryGetValue(index, out var itemView) || itemView == null)
|
||||
{
|
||||
itemView = ItemViewCreator(item);
|
||||
if (itemView != null)
|
||||
{
|
||||
itemView.Parent = this;
|
||||
_itemViewCache[index] = itemView;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemView != null)
|
||||
{
|
||||
// Measure with large height to get natural size
|
||||
var availableSize = new SKSize(bounds.Width, float.MaxValue);
|
||||
var measuredSize = itemView.Measure(availableSize);
|
||||
|
||||
// Store individual item height (with minimum of default height)
|
||||
var measuredHeight = Math.Max(measuredSize.Height, _itemHeight);
|
||||
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
|
||||
{
|
||||
_itemHeights[index] = measuredHeight;
|
||||
// Request redraw if height changed significantly
|
||||
if (Math.Abs(cachedHeight - measuredHeight) > 5)
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// Arrange with the actual measured height
|
||||
var actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
|
||||
itemView.Arrange(actualBounds);
|
||||
itemView.Draw(canvas);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] DrawItem {index} - ItemViewCreator is NULL, falling back to ToString");
|
||||
}
|
||||
|
||||
// Draw separator
|
||||
paint.Color = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
paint.Style = SKPaintStyle.Stroke;
|
||||
|
|
@ -281,8 +410,27 @@ public class SkiaItemsView : SkiaView
|
|||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] OnPointerPressed - x={e.X}, y={e.Y}, Bounds={Bounds}, ScreenBounds={ScreenBounds}, ItemCount={_items.Count}");
|
||||
if (!IsEnabled) return;
|
||||
|
||||
// Check if clicking on scrollbar thumb
|
||||
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
|
||||
{
|
||||
var thumbBounds = GetScrollbarThumbBounds();
|
||||
if (thumbBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isDraggingScrollbar = true;
|
||||
_scrollbarDragStartY = e.Y;
|
||||
_scrollbarDragStartScrollOffset = _scrollOffset;
|
||||
// Cache values to prevent stutter
|
||||
var thumbHeight = Math.Max(20, Bounds.Height * (Bounds.Height / TotalContentHeight));
|
||||
_scrollbarDragAvailableTrack = Bounds.Height - thumbHeight;
|
||||
_scrollbarDragMaxScroll = MaxScrollOffset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular content drag
|
||||
_isDragging = true;
|
||||
_dragStartY = e.Y;
|
||||
_dragStartOffset = _scrollOffset;
|
||||
|
|
@ -290,8 +438,39 @@ public class SkiaItemsView : SkiaView
|
|||
_velocity = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bounds of the scrollbar thumb in screen coordinates.
|
||||
/// </summary>
|
||||
private SKRect GetScrollbarThumbBounds()
|
||||
{
|
||||
// Use ScreenBounds for hit testing (input events use screen coordinates)
|
||||
var screenBounds = ScreenBounds;
|
||||
var viewportRatio = screenBounds.Height / TotalContentHeight;
|
||||
var thumbHeight = Math.Max(20, screenBounds.Height * viewportRatio);
|
||||
var scrollRatio = MaxScrollOffset > 0 ? _scrollOffset / MaxScrollOffset : 0;
|
||||
var thumbY = screenBounds.Top + (screenBounds.Height - thumbHeight) * scrollRatio;
|
||||
|
||||
return new SKRect(
|
||||
screenBounds.Right - _scrollBarWidth,
|
||||
thumbY,
|
||||
screenBounds.Right,
|
||||
thumbY + thumbHeight);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
// Handle scrollbar dragging - use cached values to prevent stutter
|
||||
if (_isDraggingScrollbar)
|
||||
{
|
||||
if (_scrollbarDragAvailableTrack > 0)
|
||||
{
|
||||
var deltaY = e.Y - _scrollbarDragStartY;
|
||||
var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragMaxScroll;
|
||||
SetScrollOffset(_scrollbarDragStartScrollOffset + scrollDelta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isDragging) return;
|
||||
|
||||
var delta = _dragStartY - e.Y;
|
||||
|
|
@ -311,6 +490,13 @@ public class SkiaItemsView : SkiaView
|
|||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
// Handle scrollbar drag release
|
||||
if (_isDraggingScrollbar)
|
||||
{
|
||||
_isDraggingScrollbar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
_isDragging = false;
|
||||
|
|
@ -319,9 +505,25 @@ public class SkiaItemsView : SkiaView
|
|||
var totalDrag = Math.Abs(e.Y - _dragStartY);
|
||||
if (totalDrag < 5)
|
||||
{
|
||||
// This was a tap - find which item was tapped
|
||||
var tapY = e.Y + _scrollOffset - Bounds.Top;
|
||||
var tappedIndex = (int)(tapY / (_itemHeight + _itemSpacing));
|
||||
// This was a tap - find which item was tapped using variable heights
|
||||
var screenBounds = ScreenBounds;
|
||||
var localY = e.Y - screenBounds.Top + _scrollOffset;
|
||||
|
||||
// Find tapped index by walking through item heights
|
||||
int tappedIndex = -1;
|
||||
float cumulativeY = 0;
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
{
|
||||
var itemH = GetItemHeight(i);
|
||||
if (localY >= cumulativeY && localY < cumulativeY + itemH)
|
||||
{
|
||||
tappedIndex = i;
|
||||
break;
|
||||
}
|
||||
cumulativeY += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SkiaItemsView] Tap at Y={e.Y}, screenBounds.Top={screenBounds.Top}, scrollOffset={_scrollOffset}, localY={localY}, index={tappedIndex}");
|
||||
|
||||
if (tappedIndex >= 0 && tappedIndex < _items.Count)
|
||||
{
|
||||
|
|
@ -331,6 +533,24 @@ public class SkiaItemsView : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total Y scroll offset from all parent ScrollViews.
|
||||
/// </summary>
|
||||
private float GetTotalParentScrollY()
|
||||
{
|
||||
float total = 0;
|
||||
var parent = Parent;
|
||||
while (parent != null)
|
||||
{
|
||||
if (parent is SkiaScrollView scrollView)
|
||||
{
|
||||
total += scrollView.ScrollY;
|
||||
}
|
||||
parent = parent.Parent;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
protected virtual void OnItemTapped(int index, object item)
|
||||
{
|
||||
SelectedIndex = index;
|
||||
|
|
@ -361,7 +581,7 @@ public class SkiaItemsView : SkiaView
|
|||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
|
||||
var targetOffset = index * (_itemHeight + _itemSpacing);
|
||||
var targetOffset = GetItemOffset(index);
|
||||
SetScrollOffset(targetOffset);
|
||||
}
|
||||
|
||||
|
|
@ -436,8 +656,8 @@ public class SkiaItemsView : SkiaView
|
|||
|
||||
private void EnsureIndexVisible(int index)
|
||||
{
|
||||
var itemTop = index * (_itemHeight + _itemSpacing);
|
||||
var itemBottom = itemTop + _itemHeight;
|
||||
var itemTop = GetItemOffset(index);
|
||||
var itemBottom = itemTop + GetItemHeight(index);
|
||||
|
||||
if (itemTop < _scrollOffset)
|
||||
{
|
||||
|
|
@ -452,12 +672,43 @@ public class SkiaItemsView : SkiaView
|
|||
protected int ItemCount => _items.Count;
|
||||
protected object? GetItemAt(int index) => index >= 0 && index < _items.Count ? _items[index] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Override HitTest to handle scrollbar clicks properly.
|
||||
/// HitTest receives content-space coordinates (already transformed by parent ScrollView).
|
||||
/// </summary>
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
// HitTest uses Bounds (content space) - coordinates are transformed by parent
|
||||
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
|
||||
return null;
|
||||
|
||||
// Check scrollbar area FIRST before content
|
||||
// This ensures scrollbar clicks are handled by this view
|
||||
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
|
||||
{
|
||||
var trackArea = new SKRect(Bounds.Right - _scrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom);
|
||||
if (trackArea.Contains(x, y))
|
||||
return this;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var width = availableSize.Width < float.MaxValue ? availableSize.Width : 200;
|
||||
var height = availableSize.Height < float.MaxValue ? availableSize.Height : 300;
|
||||
|
||||
// Clear item caches when width changes significantly (items need re-measurement for text wrapping)
|
||||
if (Math.Abs(width - _lastMeasuredWidth) > 5)
|
||||
{
|
||||
_itemHeights.Clear();
|
||||
_itemViewCache.Clear();
|
||||
_lastMeasuredWidth = width;
|
||||
}
|
||||
|
||||
// Items view takes all available space
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? availableSize.Width : 200,
|
||||
availableSize.Height < float.MaxValue ? availableSize.Height : 300);
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
|
|
|||
|
|
@ -7,24 +7,319 @@ using Microsoft.Maui.Platform.Linux.Rendering;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered label control for displaying text.
|
||||
/// Skia-rendered label control for displaying text with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaLabel : SkiaView
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsItalic { get; set; }
|
||||
public bool IsUnderline { get; set; }
|
||||
public bool IsStrikethrough { get; set; }
|
||||
public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
|
||||
public TextAlignment VerticalTextAlignment { get; set; } = TextAlignment.Center;
|
||||
public LineBreakMode LineBreakMode { get; set; } = LineBreakMode.TailTruncation;
|
||||
public int MaxLines { get; set; } = 0; // 0 = unlimited
|
||||
public float LineHeight { get; set; } = 1.2f;
|
||||
public float CharacterSpacing { get; set; }
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Text),
|
||||
typeof(string),
|
||||
typeof(SkiaLabel),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaLabel),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaLabel),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsBold.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsBoldProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsBold),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsItalic.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsItalicProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsItalic),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsUnderline.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsUnderlineProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsUnderline),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsStrikethrough.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsStrikethroughProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsStrikethrough),
|
||||
typeof(bool),
|
||||
typeof(SkiaLabel),
|
||||
false,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalTextAlignment.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalTextAlignmentProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalTextAlignment),
|
||||
typeof(TextAlignment),
|
||||
typeof(SkiaLabel),
|
||||
TextAlignment.Start,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalTextAlignment.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalTextAlignmentProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalTextAlignment),
|
||||
typeof(TextAlignment),
|
||||
typeof(SkiaLabel),
|
||||
TextAlignment.Center,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineBreakMode.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineBreakModeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineBreakMode),
|
||||
typeof(LineBreakMode),
|
||||
typeof(SkiaLabel),
|
||||
LineBreakMode.TailTruncation,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MaxLines.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaxLinesProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MaxLines),
|
||||
typeof(int),
|
||||
typeof(SkiaLabel),
|
||||
0,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for LineHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LineHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(LineHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
1.2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CharacterSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CharacterSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CharacterSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaLabel),
|
||||
0f,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Padding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Padding),
|
||||
typeof(SKRect),
|
||||
typeof(SkiaLabel),
|
||||
SKRect.Empty,
|
||||
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text content.
|
||||
/// </summary>
|
||||
public string Text
|
||||
{
|
||||
get => (string)GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is bold.
|
||||
/// </summary>
|
||||
public bool IsBold
|
||||
{
|
||||
get => (bool)GetValue(IsBoldProperty);
|
||||
set => SetValue(IsBoldProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text is italic.
|
||||
/// </summary>
|
||||
public bool IsItalic
|
||||
{
|
||||
get => (bool)GetValue(IsItalicProperty);
|
||||
set => SetValue(IsItalicProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text has underline.
|
||||
/// </summary>
|
||||
public bool IsUnderline
|
||||
{
|
||||
get => (bool)GetValue(IsUnderlineProperty);
|
||||
set => SetValue(IsUnderlineProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the text has strikethrough.
|
||||
/// </summary>
|
||||
public bool IsStrikethrough
|
||||
{
|
||||
get => (bool)GetValue(IsStrikethroughProperty);
|
||||
set => SetValue(IsStrikethroughProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal text alignment.
|
||||
/// </summary>
|
||||
public TextAlignment HorizontalTextAlignment
|
||||
{
|
||||
get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
|
||||
set => SetValue(HorizontalTextAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical text alignment.
|
||||
/// </summary>
|
||||
public TextAlignment VerticalTextAlignment
|
||||
{
|
||||
get => (TextAlignment)GetValue(VerticalTextAlignmentProperty);
|
||||
set => SetValue(VerticalTextAlignmentProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line break mode.
|
||||
/// </summary>
|
||||
public LineBreakMode LineBreakMode
|
||||
{
|
||||
get => (LineBreakMode)GetValue(LineBreakModeProperty);
|
||||
set => SetValue(LineBreakModeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of lines. 0 = unlimited.
|
||||
/// </summary>
|
||||
public int MaxLines
|
||||
{
|
||||
get => (int)GetValue(MaxLinesProperty);
|
||||
set => SetValue(MaxLinesProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the line height multiplier.
|
||||
/// </summary>
|
||||
public float LineHeight
|
||||
{
|
||||
get => (float)GetValue(LineHeightProperty);
|
||||
set => SetValue(LineHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the character spacing.
|
||||
/// </summary>
|
||||
public float CharacterSpacing
|
||||
{
|
||||
get => (float)GetValue(CharacterSpacingProperty);
|
||||
set => SetValue(CharacterSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding.
|
||||
/// </summary>
|
||||
public SKRect Padding
|
||||
{
|
||||
get => (SKRect)GetValue(PaddingProperty);
|
||||
set => SetValue(PaddingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal alignment (compatibility property).
|
||||
/// </summary>
|
||||
public SkiaTextAlignment HorizontalAlignment
|
||||
{
|
||||
get => HorizontalTextAlignment switch
|
||||
|
|
@ -42,6 +337,10 @@ public class SkiaLabel : SkiaView
|
|||
_ => TextAlignment.Start
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical alignment (compatibility property).
|
||||
/// </summary>
|
||||
public SkiaVerticalAlignment VerticalAlignment
|
||||
{
|
||||
get => VerticalTextAlignment switch
|
||||
|
|
@ -59,7 +358,45 @@ public class SkiaLabel : SkiaView
|
|||
_ => TextAlignment.Start
|
||||
};
|
||||
}
|
||||
public SKRect Padding { get; set; } = new SKRect(0, 0, 0, 0);
|
||||
|
||||
#endregion
|
||||
|
||||
private static SKTypeface? _cachedTypeface;
|
||||
|
||||
private void OnTextChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnFontChanged()
|
||||
{
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private static SKTypeface GetLinuxTypeface()
|
||||
{
|
||||
if (_cachedTypeface != null) return _cachedTypeface;
|
||||
|
||||
// Try common Linux font paths
|
||||
string[] fontPaths = {
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
|
||||
};
|
||||
|
||||
foreach (var path in fontPaths)
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
_cachedTypeface = SKTypeface.FromFile(path);
|
||||
if (_cachedTypeface != null) return _cachedTypeface;
|
||||
}
|
||||
}
|
||||
return SKTypeface.Default;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
|
|
@ -71,8 +408,11 @@ public class SkiaLabel : SkiaView
|
|||
SKFontStyleWidth.Normal,
|
||||
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
|
||||
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
|
||||
if (typeface == null || typeface == SKTypeface.Default)
|
||||
{
|
||||
typeface = GetLinuxTypeface();
|
||||
}
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var paint = new SKPaint(font)
|
||||
|
|
@ -89,13 +429,17 @@ public class SkiaLabel : SkiaView
|
|||
bounds.Bottom - Padding.Bottom);
|
||||
|
||||
// Handle single line vs multiline
|
||||
if (MaxLines == 1 || !Text.Contains('\n'))
|
||||
// Use DrawMultiLineWithWrapping when: MaxLines > 1, text has newlines, OR WordWrap is enabled
|
||||
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n') ||
|
||||
LineBreakMode == LineBreakMode.WordWrap ||
|
||||
LineBreakMode == LineBreakMode.CharacterWrap;
|
||||
if (needsMultiLine)
|
||||
{
|
||||
DrawSingleLine(canvas, paint, font, contentBounds);
|
||||
DrawMultiLineWithWrapping(canvas, paint, font, contentBounds);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawMultiLine(canvas, paint, font, contentBounds);
|
||||
DrawSingleLine(canvas, paint, font, contentBounds);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,10 +504,140 @@ public class SkiaLabel : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawMultiLineWithWrapping(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
|
||||
{
|
||||
// Handle inverted or zero-height/width bounds
|
||||
var effectiveBounds = bounds;
|
||||
|
||||
// Fix invalid height
|
||||
if (bounds.Height <= 0)
|
||||
{
|
||||
var effectiveLH = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var estimatedHeight = MaxLines > 0 ? MaxLines * FontSize * effectiveLH : FontSize * effectiveLH * 10;
|
||||
effectiveBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + estimatedHeight);
|
||||
}
|
||||
|
||||
// Fix invalid width - use a reasonable default if width is invalid or extremely large
|
||||
float effectiveWidth = effectiveBounds.Width;
|
||||
if (effectiveWidth <= 0)
|
||||
{
|
||||
// Use a default width based on canvas
|
||||
effectiveWidth = 400; // Reasonable default
|
||||
}
|
||||
|
||||
// Note: Previously had width capping logic here that reduced effective width
|
||||
// to 60% for multiline labels. Removed - the layout system should now provide
|
||||
// correct widths, and artificially capping causes text to wrap too early.
|
||||
|
||||
// First, word-wrap the text to fit within bounds
|
||||
var wrappedLines = WrapText(paint, Text, effectiveWidth);
|
||||
|
||||
// LineHeight of -1 or <= 0 means "use default" - use 1.2 as default multiplier
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var lineSpacing = FontSize * effectiveLineHeight;
|
||||
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
|
||||
|
||||
// Calculate total height
|
||||
var totalHeight = maxLinesToDraw * lineSpacing;
|
||||
|
||||
// Calculate starting Y based on vertical alignment
|
||||
float startY = VerticalTextAlignment switch
|
||||
{
|
||||
TextAlignment.Start => effectiveBounds.Top + FontSize,
|
||||
TextAlignment.Center => effectiveBounds.MidY - totalHeight / 2 + FontSize,
|
||||
TextAlignment.End => effectiveBounds.Bottom - totalHeight + FontSize,
|
||||
_ => effectiveBounds.Top + FontSize
|
||||
};
|
||||
|
||||
for (int i = 0; i < maxLinesToDraw; i++)
|
||||
{
|
||||
var line = wrappedLines[i];
|
||||
|
||||
// Add ellipsis if this is the last line and there are more lines
|
||||
bool isLastLine = i == maxLinesToDraw - 1;
|
||||
bool hasMoreContent = maxLinesToDraw < wrappedLines.Count;
|
||||
if (isLastLine && hasMoreContent && LineBreakMode == LineBreakMode.TailTruncation)
|
||||
{
|
||||
line = TruncateTextWithEllipsis(paint, line, effectiveWidth);
|
||||
}
|
||||
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(line, ref textBounds);
|
||||
|
||||
float x = HorizontalTextAlignment switch
|
||||
{
|
||||
TextAlignment.Start => effectiveBounds.Left,
|
||||
TextAlignment.Center => effectiveBounds.MidX - textBounds.Width / 2,
|
||||
TextAlignment.End => effectiveBounds.Right - textBounds.Width,
|
||||
_ => effectiveBounds.Left
|
||||
};
|
||||
|
||||
float y = startY + i * lineSpacing;
|
||||
|
||||
// Don't break early for inverted bounds - just draw
|
||||
if (effectiveBounds.Height > 0 && y > effectiveBounds.Bottom)
|
||||
break;
|
||||
|
||||
canvas.DrawText(line, x, y, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> WrapText(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
// Split by newlines first
|
||||
var paragraphs = text.Split('\n');
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(paragraph))
|
||||
{
|
||||
result.Add("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if paragraph fits in one line
|
||||
if (paint.MeasureText(paragraph) <= maxWidth)
|
||||
{
|
||||
result.Add(paragraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word wrap this paragraph
|
||||
var words = paragraph.Split(' ');
|
||||
var currentLine = "";
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
|
||||
var lineWidth = paint.MeasureText(testLine);
|
||||
|
||||
if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
result.Add(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(currentLine))
|
||||
{
|
||||
result.Add(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void DrawMultiLine(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
|
||||
{
|
||||
var lines = Text.Split('\n');
|
||||
var lineSpacing = FontSize * LineHeight;
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var lineSpacing = FontSize * effectiveLineHeight;
|
||||
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
|
||||
|
||||
// Calculate total height
|
||||
|
|
@ -208,6 +682,42 @@ public class SkiaLabel : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates text and ALWAYS adds ellipsis (used when there's more content to indicate).
|
||||
/// </summary>
|
||||
private string TruncateTextWithEllipsis(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
const string ellipsis = "...";
|
||||
var ellipsisWidth = paint.MeasureText(ellipsis);
|
||||
var textWidth = paint.MeasureText(text);
|
||||
|
||||
// If text + ellipsis fits, just append ellipsis
|
||||
if (textWidth + ellipsisWidth <= maxWidth)
|
||||
return text + ellipsis;
|
||||
|
||||
// Otherwise, truncate to make room for ellipsis
|
||||
var availableWidth = maxWidth - ellipsisWidth;
|
||||
if (availableWidth <= 0)
|
||||
return ellipsis;
|
||||
|
||||
// Binary search for the right length
|
||||
int low = 0;
|
||||
int high = text.Length;
|
||||
|
||||
while (low < high)
|
||||
{
|
||||
int mid = (low + high + 1) / 2;
|
||||
var substring = text.Substring(0, mid);
|
||||
|
||||
if (paint.MeasureText(substring) <= availableWidth)
|
||||
low = mid;
|
||||
else
|
||||
high = mid - 1;
|
||||
}
|
||||
|
||||
return text.Substring(0, low) + ellipsis;
|
||||
}
|
||||
|
||||
private string TruncateText(SKPaint paint, string text, float maxWidth)
|
||||
{
|
||||
const string ellipsis = "...";
|
||||
|
|
@ -252,33 +762,53 @@ public class SkiaLabel : SkiaView
|
|||
SKFontStyleWidth.Normal,
|
||||
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
|
||||
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
|
||||
?? SKTypeface.Default;
|
||||
// Use same typeface logic as OnDraw to ensure consistent measurement
|
||||
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
|
||||
if (typeface == null || typeface == SKTypeface.Default)
|
||||
{
|
||||
typeface = GetLinuxTypeface();
|
||||
}
|
||||
|
||||
using var font = new SKFont(typeface, FontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
|
||||
if (MaxLines == 1 || !Text.Contains('\n'))
|
||||
// Use multiline when: MaxLines > 1, text has newlines, OR WordWrap is enabled
|
||||
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n') ||
|
||||
LineBreakMode == LineBreakMode.WordWrap ||
|
||||
LineBreakMode == LineBreakMode.CharacterWrap;
|
||||
if (!needsMultiLine)
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
paint.MeasureText(Text, ref textBounds);
|
||||
|
||||
// Add small buffer for font rendering tolerance
|
||||
const float widthBuffer = 4f;
|
||||
|
||||
return new SKSize(
|
||||
textBounds.Width + Padding.Left + Padding.Right,
|
||||
textBounds.Width + Padding.Left + Padding.Right + widthBuffer,
|
||||
textBounds.Height + Padding.Top + Padding.Bottom);
|
||||
}
|
||||
else
|
||||
{
|
||||
var lines = Text.Split('\n');
|
||||
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
|
||||
// Use available width for word wrapping measurement
|
||||
var wrapWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
if (wrapWidth <= 0)
|
||||
{
|
||||
wrapWidth = float.MaxValue; // No wrapping if no width constraint
|
||||
}
|
||||
|
||||
// Wrap text to get actual line count
|
||||
var wrappedLines = WrapText(paint, Text, wrapWidth);
|
||||
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
|
||||
|
||||
float maxWidth = 0;
|
||||
foreach (var line in lines.Take(maxLinesToMeasure))
|
||||
foreach (var line in wrappedLines.Take(maxLinesToMeasure))
|
||||
{
|
||||
maxWidth = Math.Max(maxWidth, paint.MeasureText(line));
|
||||
}
|
||||
|
||||
var totalHeight = maxLinesToMeasure * FontSize * LineHeight;
|
||||
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
|
||||
var totalHeight = maxLinesToMeasure * FontSize * effectiveLineHeight;
|
||||
|
||||
return new SKSize(
|
||||
maxWidth + Padding.Left + Padding.Right,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
|
|
@ -32,6 +33,20 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
/// </summary>
|
||||
public bool ClipToBounds { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to layout children.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to layout children
|
||||
foreach (var child in _children)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child view.
|
||||
/// </summary>
|
||||
|
|
@ -44,6 +59,13 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
|
||||
_children.Add(child);
|
||||
child.Parent = this;
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
|
@ -88,6 +110,13 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
|
||||
_children.Insert(index, child);
|
||||
child.Parent = this;
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
|
|
@ -128,6 +157,31 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background if set (for layouts inside CollectionView items)
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
}
|
||||
|
||||
// Log for StackLayout
|
||||
if (this is SkiaStackLayout)
|
||||
{
|
||||
bool hasCV = false;
|
||||
foreach (var c in _children)
|
||||
{
|
||||
if (c is SkiaCollectionView) hasCV = true;
|
||||
}
|
||||
if (hasCV)
|
||||
{
|
||||
Console.WriteLine($"[SkiaStackLayout+CV] OnDraw - bounds={bounds}, children={_children.Count}");
|
||||
foreach (var c in _children)
|
||||
{
|
||||
Console.WriteLine($"[SkiaStackLayout+CV] Child: {c.GetType().Name}, IsVisible={c.IsVisible}, Bounds={c.Bounds}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw children in order
|
||||
foreach (var child in _children)
|
||||
{
|
||||
|
|
@ -140,8 +194,14 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
|
||||
if (!IsVisible || !IsEnabled || !Bounds.Contains(new SKPoint(x, y)))
|
||||
{
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Miss - x={x}, y={y}, Bounds={Bounds}, IsVisible={IsVisible}, IsEnabled={IsEnabled}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hit test children in reverse order (top-most first)
|
||||
for (int i = _children.Count - 1; i >= 0; i--)
|
||||
|
|
@ -149,11 +209,73 @@ public abstract class SkiaLayoutView : SkiaView
|
|||
var child = _children[i];
|
||||
var hit = child.HitTest(x, y);
|
||||
if (hit != null)
|
||||
{
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Hit child - x={x}, y={y}, Bounds={Bounds}, child={hit.GetType().Name}");
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
if (this is SkiaBorder)
|
||||
{
|
||||
Console.WriteLine($"[SkiaBorder.HitTest] Hit self - x={x}, y={y}, Bounds={Bounds}, children={_children.Count}");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer pressed events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer released events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerReleased(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward pointer moved events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnPointerMoved(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward scroll events to the appropriate child.
|
||||
/// </summary>
|
||||
public override void OnScroll(ScrollEventArgs e)
|
||||
{
|
||||
// Find which child was hit and forward the event
|
||||
var hit = HitTest(e.X, e.Y);
|
||||
if (hit != null && hit != this)
|
||||
{
|
||||
hit.OnScroll(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -168,8 +290,18 @@ public class SkiaStackLayout : SkiaLayoutView
|
|||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var contentWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
var contentHeight = availableSize.Height - Padding.Top - Padding.Bottom;
|
||||
// Handle NaN/Infinity in padding
|
||||
var paddingLeft = float.IsNaN(Padding.Left) ? 0 : Padding.Left;
|
||||
var paddingRight = float.IsNaN(Padding.Right) ? 0 : Padding.Right;
|
||||
var paddingTop = float.IsNaN(Padding.Top) ? 0 : Padding.Top;
|
||||
var paddingBottom = float.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom;
|
||||
|
||||
var contentWidth = availableSize.Width - paddingLeft - paddingRight;
|
||||
var contentHeight = availableSize.Height - paddingTop - paddingBottom;
|
||||
|
||||
// Clamp negative sizes to 0
|
||||
if (contentWidth < 0 || float.IsNaN(contentWidth)) contentWidth = 0;
|
||||
if (contentHeight < 0 || float.IsNaN(contentHeight)) contentHeight = 0;
|
||||
|
||||
float totalWidth = 0;
|
||||
float totalHeight = 0;
|
||||
|
|
@ -184,15 +316,19 @@ public class SkiaStackLayout : SkiaLayoutView
|
|||
|
||||
var childSize = child.Measure(childAvailable);
|
||||
|
||||
// Skip NaN sizes from child measurements
|
||||
var childWidth = float.IsNaN(childSize.Width) ? 0 : childSize.Width;
|
||||
var childHeight = float.IsNaN(childSize.Height) ? 0 : childSize.Height;
|
||||
|
||||
if (Orientation == StackOrientation.Vertical)
|
||||
{
|
||||
totalHeight += childSize.Height;
|
||||
maxWidth = Math.Max(maxWidth, childSize.Width);
|
||||
totalHeight += childHeight;
|
||||
maxWidth = Math.Max(maxWidth, childWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
totalWidth += childSize.Width;
|
||||
maxHeight = Math.Max(maxHeight, childSize.Height);
|
||||
totalWidth += childWidth;
|
||||
maxHeight = Math.Max(maxHeight, childHeight);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,21 +340,26 @@ public class SkiaStackLayout : SkiaLayoutView
|
|||
{
|
||||
totalHeight += totalSpacing;
|
||||
return new SKSize(
|
||||
maxWidth + Padding.Left + Padding.Right,
|
||||
totalHeight + Padding.Top + Padding.Bottom);
|
||||
maxWidth + paddingLeft + paddingRight,
|
||||
totalHeight + paddingTop + paddingBottom);
|
||||
}
|
||||
else
|
||||
{
|
||||
totalWidth += totalSpacing;
|
||||
return new SKSize(
|
||||
totalWidth + Padding.Left + Padding.Right,
|
||||
maxHeight + Padding.Top + Padding.Bottom);
|
||||
totalWidth + paddingLeft + paddingRight,
|
||||
maxHeight + paddingTop + paddingBottom);
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
|
||||
// Clamp content dimensions if infinite - use reasonable defaults
|
||||
var contentWidth = float.IsInfinity(content.Width) || float.IsNaN(content.Width) ? 800f : content.Width;
|
||||
var contentHeight = float.IsInfinity(content.Height) || float.IsNaN(content.Height) ? 600f : content.Height;
|
||||
|
||||
float offset = 0;
|
||||
|
||||
foreach (var child in Children)
|
||||
|
|
@ -227,27 +368,80 @@ public class SkiaStackLayout : SkiaLayoutView
|
|||
|
||||
var childDesired = child.DesiredSize;
|
||||
|
||||
// Handle NaN and Infinity in desired size
|
||||
var childWidth = float.IsNaN(childDesired.Width) || float.IsInfinity(childDesired.Width)
|
||||
? contentWidth
|
||||
: childDesired.Width;
|
||||
var childHeight = float.IsNaN(childDesired.Height) || float.IsInfinity(childDesired.Height)
|
||||
? contentHeight
|
||||
: childDesired.Height;
|
||||
|
||||
SKRect childBounds;
|
||||
if (Orientation == StackOrientation.Vertical)
|
||||
{
|
||||
// For ScrollView children, give them the remaining viewport height
|
||||
// Clamp to avoid giving them their content size
|
||||
var remainingHeight = Math.Max(0, contentHeight - offset);
|
||||
var useHeight = child is SkiaScrollView
|
||||
? remainingHeight
|
||||
: Math.Min(childHeight, remainingHeight > 0 ? remainingHeight : childHeight);
|
||||
|
||||
childBounds = new SKRect(
|
||||
content.Left,
|
||||
content.Top + offset,
|
||||
content.Right,
|
||||
content.Top + offset + childDesired.Height);
|
||||
offset += childDesired.Height + Spacing;
|
||||
content.Left + contentWidth,
|
||||
content.Top + offset + useHeight);
|
||||
offset += useHeight + Spacing;
|
||||
}
|
||||
else
|
||||
{
|
||||
// For ScrollView children, give them the remaining viewport width
|
||||
var remainingWidth = Math.Max(0, contentWidth - offset);
|
||||
var useWidth = child is SkiaScrollView
|
||||
? remainingWidth
|
||||
: Math.Min(childWidth, remainingWidth > 0 ? remainingWidth : childWidth);
|
||||
|
||||
// Respect child's VerticalOptions for horizontal layouts
|
||||
var useHeight = Math.Min(childHeight, contentHeight);
|
||||
float childTop = content.Top;
|
||||
float childBottom = content.Top + useHeight;
|
||||
|
||||
var verticalOptions = child.VerticalOptions;
|
||||
var alignmentValue = (int)verticalOptions.Alignment;
|
||||
|
||||
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
|
||||
if (alignmentValue == 1) // Center
|
||||
{
|
||||
childTop = content.Top + (contentHeight - useHeight) / 2;
|
||||
childBottom = childTop + useHeight;
|
||||
}
|
||||
else if (alignmentValue == 2) // End
|
||||
{
|
||||
childTop = content.Top + contentHeight - useHeight;
|
||||
childBottom = content.Top + contentHeight;
|
||||
}
|
||||
else if (alignmentValue == 3) // Fill
|
||||
{
|
||||
childTop = content.Top;
|
||||
childBottom = content.Top + contentHeight;
|
||||
}
|
||||
|
||||
childBounds = new SKRect(
|
||||
content.Left + offset,
|
||||
content.Top,
|
||||
content.Left + offset + childDesired.Width,
|
||||
content.Bottom);
|
||||
offset += childDesired.Width + Spacing;
|
||||
childTop,
|
||||
content.Left + offset + useWidth,
|
||||
childBottom);
|
||||
offset += useWidth + Spacing;
|
||||
}
|
||||
|
||||
child.Arrange(childBounds);
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
childBounds.Left + (float)margin.Left,
|
||||
childBounds.Top + (float)margin.Top,
|
||||
childBounds.Right - (float)margin.Right,
|
||||
childBounds.Bottom - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
|
@ -332,14 +526,73 @@ public class SkiaGrid : SkiaLayoutView
|
|||
var contentWidth = availableSize.Width - Padding.Left - Padding.Right;
|
||||
var contentHeight = availableSize.Height - Padding.Top - Padding.Bottom;
|
||||
|
||||
var rowCount = Math.Max(1, _rowDefinitions.Count);
|
||||
var columnCount = Math.Max(1, _columnDefinitions.Count);
|
||||
// Handle NaN/Infinity
|
||||
if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
|
||||
if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
|
||||
|
||||
// Calculate column widths
|
||||
_columnWidths = CalculateSizes(_columnDefinitions, contentWidth, ColumnSpacing, columnCount);
|
||||
_rowHeights = CalculateSizes(_rowDefinitions, contentHeight, RowSpacing, rowCount);
|
||||
var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
|
||||
var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
|
||||
|
||||
// Measure children to adjust auto sizes
|
||||
// First pass: measure children in Auto columns to get natural widths
|
||||
var columnNaturalWidths = new float[columnCount];
|
||||
var rowNaturalHeights = new float[rowCount];
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
|
||||
var pos = GetPosition(child);
|
||||
|
||||
// For Auto columns, measure with infinite width to get natural size
|
||||
var def = pos.Column < _columnDefinitions.Count ? _columnDefinitions[pos.Column] : GridLength.Star;
|
||||
if (def.IsAuto && pos.ColumnSpan == 1)
|
||||
{
|
||||
var childSize = child.Measure(new SKSize(float.PositiveInfinity, float.PositiveInfinity));
|
||||
var childWidth = float.IsNaN(childSize.Width) ? 0 : childSize.Width;
|
||||
columnNaturalWidths[pos.Column] = Math.Max(columnNaturalWidths[pos.Column], childWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate column widths - handle Auto, Absolute, and Star
|
||||
_columnWidths = CalculateSizesWithAuto(_columnDefinitions, contentWidth, ColumnSpacing, columnCount, columnNaturalWidths);
|
||||
|
||||
// Second pass: measure all children with calculated column widths
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
|
||||
var pos = GetPosition(child);
|
||||
var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
|
||||
// Give infinite height for initial measure
|
||||
var childSize = child.Measure(new SKSize(cellWidth, float.PositiveInfinity));
|
||||
|
||||
// Track max height for each row
|
||||
// Cap infinite/very large heights - child returning infinity means it doesn't have a natural height
|
||||
var childHeight = childSize.Height;
|
||||
if (float.IsNaN(childHeight) || float.IsInfinity(childHeight) || childHeight > 100000)
|
||||
{
|
||||
// Use a default minimum - will be expanded by Star sizing if finite height is available
|
||||
childHeight = 44; // Standard row height
|
||||
}
|
||||
if (pos.RowSpan == 1)
|
||||
{
|
||||
rowNaturalHeights[pos.Row] = Math.Max(rowNaturalHeights[pos.Row], childHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate row heights - use natural heights when available height is infinite or very large
|
||||
// (Some layouts pass float.MaxValue instead of PositiveInfinity)
|
||||
if (float.IsInfinity(contentHeight) || contentHeight > 100000)
|
||||
{
|
||||
_rowHeights = rowNaturalHeights;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rowHeights = CalculateSizesWithAuto(_rowDefinitions, contentHeight, RowSpacing, rowCount, rowNaturalHeights);
|
||||
}
|
||||
|
||||
// Third pass: re-measure children with actual cell sizes
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (!child.IsVisible) continue;
|
||||
|
|
@ -360,7 +613,27 @@ public class SkiaGrid : SkiaLayoutView
|
|||
totalHeight + Padding.Top + Padding.Bottom);
|
||||
}
|
||||
|
||||
private float[] CalculateSizes(List<GridLength> definitions, float available, float spacing, int count)
|
||||
private int GetMaxRow()
|
||||
{
|
||||
int maxRow = 0;
|
||||
foreach (var pos in _childPositions.Values)
|
||||
{
|
||||
maxRow = Math.Max(maxRow, pos.Row + pos.RowSpan - 1);
|
||||
}
|
||||
return maxRow;
|
||||
}
|
||||
|
||||
private int GetMaxColumn()
|
||||
{
|
||||
int maxCol = 0;
|
||||
foreach (var pos in _childPositions.Values)
|
||||
{
|
||||
maxCol = Math.Max(maxCol, pos.Column + pos.ColumnSpan - 1);
|
||||
}
|
||||
return maxCol;
|
||||
}
|
||||
|
||||
private float[] CalculateSizesWithAuto(List<GridLength> definitions, float available, float spacing, int count, float[] naturalSizes)
|
||||
{
|
||||
if (count == 0) return new float[] { available };
|
||||
|
||||
|
|
@ -381,7 +654,9 @@ public class SkiaGrid : SkiaLayoutView
|
|||
}
|
||||
else if (def.IsAuto)
|
||||
{
|
||||
sizes[i] = 0; // Will be calculated from children
|
||||
// Use natural size from measured children
|
||||
sizes[i] = naturalSizes[i];
|
||||
remainingSpace -= sizes[i];
|
||||
}
|
||||
else if (def.IsStar)
|
||||
{
|
||||
|
|
@ -389,7 +664,7 @@ public class SkiaGrid : SkiaLayoutView
|
|||
}
|
||||
}
|
||||
|
||||
// Second pass: star sizes
|
||||
// Second pass: star sizes (distribute remaining space)
|
||||
if (starTotal > 0 && remainingSpace > 0)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
|
|
@ -449,7 +724,52 @@ public class SkiaGrid : SkiaLayoutView
|
|||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
try
|
||||
{
|
||||
var content = GetContentBounds(bounds);
|
||||
|
||||
// Recalculate row heights for arrange bounds if they differ from measurement
|
||||
// This ensures Star rows expand to fill available space
|
||||
var rowCount = _rowHeights.Length > 0 ? _rowHeights.Length : 1;
|
||||
var columnCount = _columnWidths.Length > 0 ? _columnWidths.Length : 1;
|
||||
var arrangeRowHeights = _rowHeights;
|
||||
|
||||
// If we have arrange height and rows need recalculating
|
||||
if (content.Height > 0 && !float.IsInfinity(content.Height))
|
||||
{
|
||||
var measuredRowsTotal = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
|
||||
|
||||
// If arrange height is larger than measured, redistribute to Star rows
|
||||
if (content.Height > measuredRowsTotal + 1)
|
||||
{
|
||||
arrangeRowHeights = new float[rowCount];
|
||||
var extraHeight = content.Height - measuredRowsTotal;
|
||||
|
||||
// Count Star rows (implicit rows without definitions are Star)
|
||||
float totalStarWeight = 0;
|
||||
for (int i = 0; i < rowCount; i++)
|
||||
{
|
||||
var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
|
||||
if (def.IsStar) totalStarWeight += def.Value;
|
||||
}
|
||||
|
||||
// Distribute extra height to Star rows
|
||||
for (int i = 0; i < rowCount; i++)
|
||||
{
|
||||
var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
|
||||
arrangeRowHeights[i] = i < _rowHeights.Length ? _rowHeights[i] : 0;
|
||||
|
||||
if (def.IsStar && totalStarWeight > 0)
|
||||
{
|
||||
arrangeRowHeights[i] += extraHeight * (def.Value / totalStarWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
arrangeRowHeights = _rowHeights;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in Children)
|
||||
{
|
||||
|
|
@ -458,13 +778,48 @@ public class SkiaGrid : SkiaLayoutView
|
|||
var pos = GetPosition(child);
|
||||
|
||||
var x = content.Left + GetColumnOffset(pos.Column);
|
||||
var y = content.Top + GetRowOffset(pos.Row);
|
||||
var width = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
var height = GetCellHeight(pos.Row, pos.RowSpan);
|
||||
|
||||
child.Arrange(new SKRect(x, y, x + width, y + height));
|
||||
// Calculate y using arrange row heights
|
||||
float y = content.Top;
|
||||
for (int i = 0; i < Math.Min(pos.Row, arrangeRowHeights.Length); i++)
|
||||
{
|
||||
y += arrangeRowHeights[i] + RowSpacing;
|
||||
}
|
||||
|
||||
var width = GetCellWidth(pos.Column, pos.ColumnSpan);
|
||||
|
||||
// Calculate height using arrange row heights
|
||||
float height = 0;
|
||||
for (int i = pos.Row; i < Math.Min(pos.Row + pos.RowSpan, arrangeRowHeights.Length); i++)
|
||||
{
|
||||
height += arrangeRowHeights[i];
|
||||
if (i > pos.Row) height += RowSpacing;
|
||||
}
|
||||
|
||||
// Clamp infinite dimensions
|
||||
if (float.IsInfinity(width) || float.IsNaN(width))
|
||||
width = content.Width;
|
||||
if (float.IsInfinity(height) || float.IsNaN(height) || height <= 0)
|
||||
height = content.Height;
|
||||
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
x + (float)margin.Left,
|
||||
y + (float)margin.Top,
|
||||
x + width - (float)margin.Right,
|
||||
y + height - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaGrid] EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.WriteLine($"[SkiaGrid] Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
|
||||
Console.WriteLine($"[SkiaGrid] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -629,7 +984,14 @@ public class SkiaAbsoluteLayout : SkiaLayoutView
|
|||
else
|
||||
height = childBounds.Height;
|
||||
|
||||
child.Arrange(new SKRect(x, y, x + width, y + height));
|
||||
// Apply child's margin
|
||||
var margin = child.Margin;
|
||||
var marginedBounds = new SKRect(
|
||||
x + (float)margin.Left,
|
||||
y + (float)margin.Top,
|
||||
x + width - (float)margin.Right,
|
||||
y + height - (float)margin.Bottom);
|
||||
child.Arrange(marginedBounds);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ public class SkiaNavigationPage : SkiaView
|
|||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] OnPointerPressed at ({e.X}, {e.Y}), _isAnimating={_isAnimating}");
|
||||
if (_isAnimating) return;
|
||||
|
||||
// Check for back button click
|
||||
|
|
@ -357,11 +358,13 @@ public class SkiaNavigationPage : SkiaView
|
|||
{
|
||||
if (e.X < 56 && e.Y < _navigationBarHeight)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] Back button clicked");
|
||||
Pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SkiaNavigationPage] Forwarding to _currentPage: {_currentPage?.GetType().Name}");
|
||||
_currentPage?.OnPointerPressed(e);
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +406,35 @@ public class SkiaNavigationPage : SkiaView
|
|||
if (_isAnimating) return;
|
||||
_currentPage?.OnScroll(e);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible)
|
||||
return null;
|
||||
|
||||
// Back button area - return self so OnPointerPressed handles it
|
||||
if (_showBackButton && _navigationStack.Count > 0 && x < 56 && y < _navigationBarHeight)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
// Check current page
|
||||
if (_currentPage != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hit = _currentPage.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SkiaNavigationPage] HitTest error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,19 @@ public class SkiaPage : SkiaView
|
|||
// Draw content
|
||||
if (_content != null)
|
||||
{
|
||||
_content.Bounds = contentBounds;
|
||||
// Apply content's margin to the content bounds
|
||||
var margin = _content.Margin;
|
||||
var adjustedBounds = new SKRect(
|
||||
contentBounds.Left + (float)margin.Left,
|
||||
contentBounds.Top + (float)margin.Top,
|
||||
contentBounds.Right - (float)margin.Right,
|
||||
contentBounds.Bottom - (float)margin.Bottom);
|
||||
|
||||
// Measure and arrange the content before drawing
|
||||
var availableSize = new SKSize(adjustedBounds.Width, adjustedBounds.Height);
|
||||
_content.Measure(availableSize);
|
||||
_content.Arrange(adjustedBounds);
|
||||
Console.WriteLine($"[SkiaPage] Drawing content: {_content.GetType().Name}, Bounds={_content.Bounds}, IsVisible={_content.IsVisible}");
|
||||
_content.Draw(canvas);
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +245,7 @@ public class SkiaPage : SkiaView
|
|||
|
||||
public void OnAppearing()
|
||||
{
|
||||
Console.WriteLine($"[SkiaPage] OnAppearing called for: {Title}, HasListeners={Appearing != null}");
|
||||
Appearing?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
|
@ -292,13 +305,160 @@ public class SkiaPage : SkiaView
|
|||
{
|
||||
_content?.OnScroll(e);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible)
|
||||
return null;
|
||||
|
||||
// Don't check Bounds.Contains for page - it may not be set
|
||||
// Just forward to content
|
||||
|
||||
// Check content
|
||||
if (_content != null)
|
||||
{
|
||||
var hit = _content.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple content page view.
|
||||
/// Simple content page view with toolbar items support.
|
||||
/// </summary>
|
||||
public class SkiaContentPage : SkiaPage
|
||||
{
|
||||
// SkiaContentPage is essentially the same as SkiaPage
|
||||
// but represents a ContentPage specifically
|
||||
private readonly List<SkiaToolbarItem> _toolbarItems = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolbar items for this page.
|
||||
/// </summary>
|
||||
public IList<SkiaToolbarItem> ToolbarItems => _toolbarItems;
|
||||
|
||||
protected override void DrawNavigationBar(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw navigation bar background
|
||||
using var barPaint = new SKPaint
|
||||
{
|
||||
Color = TitleBarColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(bounds, barPaint);
|
||||
|
||||
// Draw title
|
||||
if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, 20);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TitleTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(Title, ref textBounds);
|
||||
|
||||
var x = bounds.Left + 56; // Leave space for back button
|
||||
var y = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(Title, x, y, textPaint);
|
||||
}
|
||||
|
||||
// Draw toolbar items on the right
|
||||
DrawToolbarItems(canvas, bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 30),
|
||||
Style = SKPaintStyle.Fill,
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 2)
|
||||
};
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom + 4), shadowPaint);
|
||||
}
|
||||
|
||||
private void DrawToolbarItems(SKCanvas canvas, SKRect navBarBounds)
|
||||
{
|
||||
var primaryItems = _toolbarItems.Where(t => t.Order == SkiaToolbarItemOrder.Primary).ToList();
|
||||
Console.WriteLine($"[SkiaContentPage] DrawToolbarItems: {primaryItems.Count} primary items, navBarBounds={navBarBounds}");
|
||||
if (primaryItems.Count == 0) return;
|
||||
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TitleTextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float rightEdge = navBarBounds.Right - 16;
|
||||
|
||||
foreach (var item in primaryItems.AsEnumerable().Reverse())
|
||||
{
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(item.Text, ref textBounds);
|
||||
|
||||
var itemWidth = textBounds.Width + 24; // Padding
|
||||
var itemLeft = rightEdge - itemWidth;
|
||||
|
||||
// Store hit area for click handling
|
||||
item.HitBounds = new SKRect(itemLeft, navBarBounds.Top, rightEdge, navBarBounds.Bottom);
|
||||
Console.WriteLine($"[SkiaContentPage] Toolbar item '{item.Text}' HitBounds set to {item.HitBounds}");
|
||||
|
||||
// Draw text
|
||||
var x = itemLeft + 12;
|
||||
var y = navBarBounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(item.Text, x, y, textPaint);
|
||||
|
||||
rightEdge = itemLeft - 8; // Gap between items
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] OnPointerPressed at ({e.X}, {e.Y}), ShowNavigationBar={ShowNavigationBar}, NavigationBarHeight={NavigationBarHeight}");
|
||||
Console.WriteLine($"[SkiaContentPage] ToolbarItems count: {_toolbarItems.Count}");
|
||||
|
||||
// Check toolbar item clicks
|
||||
if (ShowNavigationBar && e.Y < NavigationBarHeight)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] In navigation bar area, checking toolbar items");
|
||||
foreach (var item in _toolbarItems.Where(t => t.Order == SkiaToolbarItemOrder.Primary))
|
||||
{
|
||||
var bounds = item.HitBounds;
|
||||
var contains = bounds.Contains(e.X, e.Y);
|
||||
Console.WriteLine($"[SkiaContentPage] Checking item '{item.Text}', HitBounds=({bounds.Left},{bounds.Top},{bounds.Right},{bounds.Bottom}), Click=({e.X},{e.Y}), Contains={contains}, Command={item.Command != null}");
|
||||
if (contains)
|
||||
{
|
||||
Console.WriteLine($"[SkiaContentPage] Toolbar item clicked: {item.Text}");
|
||||
item.Command?.Execute(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[SkiaContentPage] No toolbar item hit");
|
||||
}
|
||||
|
||||
base.OnPointerPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a toolbar item in the navigation bar.
|
||||
/// </summary>
|
||||
public class SkiaToolbarItem
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SkiaToolbarItemOrder Order { get; set; } = SkiaToolbarItemOrder.Primary;
|
||||
public System.Windows.Input.ICommand? Command { get; set; }
|
||||
public SKRect HitBounds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of toolbar items.
|
||||
/// </summary>
|
||||
public enum SkiaToolbarItemOrder
|
||||
{
|
||||
Primary,
|
||||
Secondary
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,67 +6,301 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered picker/dropdown control.
|
||||
/// Skia-rendered picker/dropdown control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaPicker : SkiaView
|
||||
{
|
||||
private List<string> _items = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _isOpen;
|
||||
private string _title = "";
|
||||
private float _dropdownMaxHeight = 200;
|
||||
private int _hoveredItemIndex = -1;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor TitleColor { get; set; } = new SKColor(0x80, 0x80, 0x80);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor DropdownBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor SelectedItemBackgroundColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x30);
|
||||
public SKColor HoverItemBackgroundColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public string FontFamily { get; set; } = "Sans";
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float ItemHeight { get; set; } = 40;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
/// <summary>
|
||||
/// Bindable property for SelectedIndex.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectedIndexProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectedIndex),
|
||||
typeof(int),
|
||||
typeof(SkiaPicker),
|
||||
-1,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).OnSelectedIndexChanged());
|
||||
|
||||
public IList<string> Items => _items;
|
||||
/// <summary>
|
||||
/// Bindable property for Title.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Title),
|
||||
typeof(string),
|
||||
typeof(SkiaPicker),
|
||||
"",
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TextColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TextColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TitleColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TitleColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0x80, 0x80, 0x80),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BorderColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BorderColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DropdownBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DropdownBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DropdownBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for SelectedItemBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SelectedItemBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(SelectedItemBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0x21, 0x96, 0xF3, 0x30),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HoverItemBackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HoverItemBackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HoverItemBackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaPicker),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontFamily.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontFamilyProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontFamily),
|
||||
typeof(string),
|
||||
typeof(SkiaPicker),
|
||||
"Sans",
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for FontSize.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(FontSize),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ItemHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ItemHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ItemHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
40f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaPicker),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaPicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected index.
|
||||
/// </summary>
|
||||
public int SelectedIndex
|
||||
{
|
||||
get => _selectedIndex;
|
||||
set
|
||||
{
|
||||
if (_selectedIndex != value)
|
||||
{
|
||||
_selectedIndex = value;
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (int)GetValue(SelectedIndexProperty);
|
||||
set => SetValue(SelectedIndexProperty, value);
|
||||
}
|
||||
|
||||
public string? SelectedItem => _selectedIndex >= 0 && _selectedIndex < _items.Count ? _items[_selectedIndex] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title/placeholder.
|
||||
/// </summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
_title = value;
|
||||
Invalidate();
|
||||
}
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text color.
|
||||
/// </summary>
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title color.
|
||||
/// </summary>
|
||||
public SKColor TitleColor
|
||||
{
|
||||
get => (SKColor)GetValue(TitleColorProperty);
|
||||
set => SetValue(TitleColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the border color.
|
||||
/// </summary>
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the dropdown background color.
|
||||
/// </summary>
|
||||
public SKColor DropdownBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(DropdownBackgroundColorProperty);
|
||||
set => SetValue(DropdownBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected item background color.
|
||||
/// </summary>
|
||||
public SKColor SelectedItemBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedItemBackgroundColorProperty);
|
||||
set => SetValue(SelectedItemBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hover item background color.
|
||||
/// </summary>
|
||||
public SKColor HoverItemBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(HoverItemBackgroundColorProperty);
|
||||
set => SetValue(HoverItemBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font family.
|
||||
/// </summary>
|
||||
public string FontFamily
|
||||
{
|
||||
get => (string)GetValue(FontFamilyProperty);
|
||||
set => SetValue(FontFamilyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font size.
|
||||
/// </summary>
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item height.
|
||||
/// </summary>
|
||||
public float ItemHeight
|
||||
{
|
||||
get => (float)GetValue(ItemHeightProperty);
|
||||
set => SetValue(ItemHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the items list.
|
||||
/// </summary>
|
||||
public IList<string> Items => _items;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selected item.
|
||||
/// </summary>
|
||||
public string? SelectedItem => SelectedIndex >= 0 && SelectedIndex < _items.Count ? _items[SelectedIndex] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the dropdown is open.
|
||||
/// </summary>
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set
|
||||
{
|
||||
_isOpen = value;
|
||||
Invalidate();
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
{
|
||||
RegisterPopupOverlay(this, DrawDropdownOverlay);
|
||||
}
|
||||
else
|
||||
{
|
||||
UnregisterPopupOverlay(this);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly List<string> _items = new();
|
||||
private bool _isOpen;
|
||||
private float _dropdownMaxHeight = 200;
|
||||
private int _hoveredItemIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when selected index changes.
|
||||
/// </summary>
|
||||
public event EventHandler? SelectedIndexChanged;
|
||||
|
||||
public SkiaPicker()
|
||||
|
|
@ -74,25 +308,36 @@ public class SkiaPicker : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnSelectedIndexChanged()
|
||||
{
|
||||
SelectedIndexChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the items in the picker.
|
||||
/// </summary>
|
||||
public void SetItems(IEnumerable<string> items)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
if (_selectedIndex >= _items.Count)
|
||||
if (SelectedIndex >= _items.Count)
|
||||
{
|
||||
_selectedIndex = _items.Count > 0 ? 0 : -1;
|
||||
SelectedIndex = _items.Count > 0 ? 0 : -1;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void DrawDropdownOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (_items.Count == 0 || !_isOpen) return;
|
||||
// Use ScreenBounds for overlay drawing to account for scroll offset
|
||||
DrawDropdown(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawDropdown(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
|
|
@ -126,14 +371,14 @@ public class SkiaPicker : SkiaView
|
|||
};
|
||||
|
||||
string displayText;
|
||||
if (_selectedIndex >= 0 && _selectedIndex < _items.Count)
|
||||
if (SelectedIndex >= 0 && SelectedIndex < _items.Count)
|
||||
{
|
||||
displayText = _items[_selectedIndex];
|
||||
displayText = _items[SelectedIndex];
|
||||
textPaint.Color = IsEnabled ? TextColor : TextColor.WithAlpha(128);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayText = _title;
|
||||
displayText = Title;
|
||||
textPaint.Color = TitleColor;
|
||||
}
|
||||
|
||||
|
|
@ -166,14 +411,12 @@ public class SkiaPicker : SkiaView
|
|||
using var path = new SKPath();
|
||||
if (_isOpen)
|
||||
{
|
||||
// Up arrow
|
||||
path.MoveTo(centerX - arrowSize, centerY + arrowSize / 2);
|
||||
path.LineTo(centerX, centerY - arrowSize / 2);
|
||||
path.LineTo(centerX + arrowSize, centerY + arrowSize / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Down arrow
|
||||
path.MoveTo(centerX - arrowSize, centerY - arrowSize / 2);
|
||||
path.LineTo(centerX, centerY + arrowSize / 2);
|
||||
path.LineTo(centerX + arrowSize, centerY - arrowSize / 2);
|
||||
|
|
@ -242,7 +485,7 @@ public class SkiaPicker : SkiaView
|
|||
var itemRect = new SKRect(dropdownRect.Left, itemTop, dropdownRect.Right, itemTop + ItemHeight);
|
||||
|
||||
// Draw item background
|
||||
if (i == _selectedIndex)
|
||||
if (i == SelectedIndex)
|
||||
{
|
||||
using var selectedPaint = new SKPaint
|
||||
{
|
||||
|
|
@ -277,10 +520,11 @@ public class SkiaPicker : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
// Check if clicked on dropdown item
|
||||
var dropdownTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var dropdownTop = screenBounds.Bottom + 4;
|
||||
if (e.Y >= dropdownTop)
|
||||
{
|
||||
var itemIndex = (int)((e.Y - dropdownTop) / ItemHeight);
|
||||
|
|
@ -289,15 +533,11 @@ public class SkiaPicker : SkiaView
|
|||
SelectedIndex = itemIndex;
|
||||
}
|
||||
}
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if clicked on picker button
|
||||
if (e.Y < Bounds.Bottom)
|
||||
{
|
||||
_isOpen = true;
|
||||
}
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
|
|
@ -307,7 +547,9 @@ public class SkiaPicker : SkiaView
|
|||
{
|
||||
if (!_isOpen) return;
|
||||
|
||||
var dropdownTop = Bounds.Bottom + 4;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var dropdownTop = screenBounds.Bottom + 4;
|
||||
if (e.Y >= dropdownTop)
|
||||
{
|
||||
var newHovered = (int)((e.Y - dropdownTop) / ItemHeight);
|
||||
|
|
@ -341,27 +583,22 @@ public class SkiaPicker : SkiaView
|
|||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
_isOpen = !_isOpen;
|
||||
IsOpen = !IsOpen;
|
||||
e.Handled = true;
|
||||
Invalidate();
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
e.Handled = true;
|
||||
Invalidate();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
if (_isOpen && _selectedIndex > 0)
|
||||
{
|
||||
SelectedIndex--;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (!_isOpen && _selectedIndex > 0)
|
||||
if (SelectedIndex > 0)
|
||||
{
|
||||
SelectedIndex--;
|
||||
e.Handled = true;
|
||||
|
|
@ -369,12 +606,7 @@ public class SkiaPicker : SkiaView
|
|||
break;
|
||||
|
||||
case Key.Down:
|
||||
if (_isOpen && _selectedIndex < _items.Count - 1)
|
||||
{
|
||||
SelectedIndex++;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (!_isOpen && _selectedIndex < _items.Count - 1)
|
||||
if (SelectedIndex < _items.Count - 1)
|
||||
{
|
||||
SelectedIndex++;
|
||||
e.Handled = true;
|
||||
|
|
@ -383,10 +615,47 @@ public class SkiaPicker : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include dropdown area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the dropdown area
|
||||
if (_isOpen && _items.Count > 0)
|
||||
{
|
||||
var dropdownHeight = Math.Min(_items.Count * ItemHeight, _dropdownMaxHeight);
|
||||
var dropdownRect = new SKRect(
|
||||
screenBounds.Left,
|
||||
screenBounds.Bottom + 4,
|
||||
screenBounds.Right,
|
||||
screenBounds.Bottom + 4 + dropdownHeight);
|
||||
|
||||
return dropdownRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,40 +6,156 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered progress bar control.
|
||||
/// Skia-rendered progress bar control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaProgressBar : SkiaView
|
||||
{
|
||||
private double _progress;
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Progress.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ProgressProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Progress),
|
||||
typeof(double),
|
||||
typeof(SkiaProgressBar),
|
||||
0.0,
|
||||
BindingMode.TwoWay,
|
||||
coerceValue: (b, v) => Math.Clamp((double)v, 0, 1),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).OnProgressChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ProgressColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ProgressColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ProgressColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaProgressBar),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BarHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BarHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BarHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaProgressBar),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for CornerRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(CornerRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaProgressBar),
|
||||
2f,
|
||||
propertyChanged: (b, o, n) => ((SkiaProgressBar)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the progress value (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double Progress
|
||||
{
|
||||
get => _progress;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 1);
|
||||
if (_progress != clamped)
|
||||
{
|
||||
_progress = clamped;
|
||||
ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(_progress));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(ProgressProperty);
|
||||
set => SetValue(ProgressProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TrackColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ProgressColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float Height { get; set; } = 4;
|
||||
public float CornerRadius { get; set; } = 2;
|
||||
/// <summary>
|
||||
/// Gets or sets the track color.
|
||||
/// </summary>
|
||||
public SKColor TrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(TrackColorProperty);
|
||||
set => SetValue(TrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the progress color.
|
||||
/// </summary>
|
||||
public SKColor ProgressColor
|
||||
{
|
||||
get => (SKColor)GetValue(ProgressColorProperty);
|
||||
set => SetValue(ProgressColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bar height.
|
||||
/// </summary>
|
||||
public float BarHeight
|
||||
{
|
||||
get => (float)GetValue(BarHeightProperty);
|
||||
set => SetValue(BarHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the corner radius.
|
||||
/// </summary>
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when progress changes.
|
||||
/// </summary>
|
||||
public event EventHandler<ProgressChangedEventArgs>? ProgressChanged;
|
||||
|
||||
private void OnProgressChanged()
|
||||
{
|
||||
ProgressChanged?.Invoke(this, new ProgressChangedEventArgs(Progress));
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var trackY = bounds.MidY;
|
||||
var trackTop = trackY - Height / 2;
|
||||
var trackBottom = trackY + Height / 2;
|
||||
var trackTop = trackY - BarHeight / 2;
|
||||
var trackBottom = trackY + BarHeight / 2;
|
||||
|
||||
// Draw track
|
||||
using var trackPaint = new SKPaint
|
||||
|
|
@ -75,10 +191,13 @@ public class SkiaProgressBar : SkiaView
|
|||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(200, Height + 8);
|
||||
return new SKSize(200, BarHeight + 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for progress changed events.
|
||||
/// </summary>
|
||||
public class ProgressChangedEventArgs : EventArgs
|
||||
{
|
||||
public double Progress { get; }
|
||||
|
|
|
|||
|
|
@ -6,73 +6,129 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered radio button control.
|
||||
/// Skia-rendered radio button control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaRadioButton : SkiaView
|
||||
{
|
||||
private bool _isChecked;
|
||||
private string _content = "";
|
||||
private object? _value;
|
||||
private string? _groupName;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor RadioColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor UncheckedColor { get; set; } = new SKColor(0x75, 0x75, 0x75);
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float RadioSize { get; set; } = 20;
|
||||
public float Spacing { get; set; } = 8;
|
||||
public static readonly BindableProperty IsCheckedProperty =
|
||||
BindableProperty.Create(nameof(IsChecked), typeof(bool), typeof(SkiaRadioButton), false, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).OnIsCheckedChanged());
|
||||
|
||||
// Static group management
|
||||
private static readonly Dictionary<string, List<WeakReference<SkiaRadioButton>>> _groups = new();
|
||||
public static readonly BindableProperty ContentProperty =
|
||||
BindableProperty.Create(nameof(Content), typeof(string), typeof(SkiaRadioButton), "",
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(nameof(Value), typeof(object), typeof(SkiaRadioButton), null);
|
||||
|
||||
public static readonly BindableProperty GroupNameProperty =
|
||||
BindableProperty.Create(nameof(GroupName), typeof(string), typeof(SkiaRadioButton), null,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).OnGroupNameChanged((string?)o, (string?)n));
|
||||
|
||||
public static readonly BindableProperty RadioColorProperty =
|
||||
BindableProperty.Create(nameof(RadioColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty UncheckedColorProperty =
|
||||
BindableProperty.Create(nameof(UncheckedColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0x75, 0x75, 0x75),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaRadioButton), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(nameof(DisabledColor), typeof(SKColor), typeof(SkiaRadioButton), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaRadioButton), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty RadioSizeProperty =
|
||||
BindableProperty.Create(nameof(RadioSize), typeof(float), typeof(SkiaRadioButton), 20f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty SpacingProperty =
|
||||
BindableProperty.Create(nameof(Spacing), typeof(float), typeof(SkiaRadioButton), 8f,
|
||||
propertyChanged: (b, o, n) => ((SkiaRadioButton)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (_isChecked != value)
|
||||
{
|
||||
_isChecked = value;
|
||||
|
||||
if (_isChecked && !string.IsNullOrEmpty(_groupName))
|
||||
{
|
||||
UncheckOthersInGroup();
|
||||
}
|
||||
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsCheckedProperty);
|
||||
set => SetValue(IsCheckedProperty, value);
|
||||
}
|
||||
|
||||
public string Content
|
||||
{
|
||||
get => _content;
|
||||
set { _content = value ?? ""; Invalidate(); }
|
||||
get => (string)GetValue(ContentProperty);
|
||||
set => SetValue(ContentProperty, value);
|
||||
}
|
||||
|
||||
public object? Value
|
||||
{
|
||||
get => _value;
|
||||
set { _value = value; }
|
||||
get => GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, value);
|
||||
}
|
||||
|
||||
public string? GroupName
|
||||
{
|
||||
get => _groupName;
|
||||
set
|
||||
{
|
||||
if (_groupName != value)
|
||||
{
|
||||
RemoveFromGroup();
|
||||
_groupName = value;
|
||||
AddToGroup();
|
||||
}
|
||||
}
|
||||
get => (string?)GetValue(GroupNameProperty);
|
||||
set => SetValue(GroupNameProperty, value);
|
||||
}
|
||||
|
||||
public SKColor RadioColor
|
||||
{
|
||||
get => (SKColor)GetValue(RadioColorProperty);
|
||||
set => SetValue(RadioColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor UncheckedColor
|
||||
{
|
||||
get => (SKColor)GetValue(UncheckedColorProperty);
|
||||
set => SetValue(UncheckedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float RadioSize
|
||||
{
|
||||
get => (float)GetValue(RadioSizeProperty);
|
||||
set => SetValue(RadioSizeProperty, value);
|
||||
}
|
||||
|
||||
public float Spacing
|
||||
{
|
||||
get => (float)GetValue(SpacingProperty);
|
||||
set => SetValue(SpacingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static readonly Dictionary<string, List<WeakReference<SkiaRadioButton>>> _groups = new();
|
||||
|
||||
public event EventHandler? CheckedChanged;
|
||||
|
||||
public SkiaRadioButton()
|
||||
|
|
@ -80,48 +136,59 @@ public class SkiaRadioButton : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void AddToGroup()
|
||||
private void OnIsCheckedChanged()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (IsChecked && !string.IsNullOrEmpty(GroupName))
|
||||
{
|
||||
UncheckOthersInGroup();
|
||||
}
|
||||
CheckedChanged?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, IsChecked ? SkiaVisualStateManager.CommonStates.Checked : SkiaVisualStateManager.CommonStates.Unchecked);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
if (!_groups.TryGetValue(_groupName, out var group))
|
||||
private void OnGroupNameChanged(string? oldValue, string? newValue)
|
||||
{
|
||||
RemoveFromGroup(oldValue);
|
||||
AddToGroup(newValue);
|
||||
}
|
||||
|
||||
private void AddToGroup(string? groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupName)) return;
|
||||
|
||||
if (!_groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group = new List<WeakReference<SkiaRadioButton>>();
|
||||
_groups[_groupName] = group;
|
||||
_groups[groupName] = group;
|
||||
}
|
||||
|
||||
// Clean up dead references and add this one
|
||||
group.RemoveAll(wr => !wr.TryGetTarget(out _));
|
||||
group.Add(new WeakReference<SkiaRadioButton>(this));
|
||||
}
|
||||
|
||||
private void RemoveFromGroup()
|
||||
private void RemoveFromGroup(string? groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (string.IsNullOrEmpty(groupName)) return;
|
||||
|
||||
if (_groups.TryGetValue(_groupName, out var group))
|
||||
if (_groups.TryGetValue(groupName, out var group))
|
||||
{
|
||||
group.RemoveAll(wr => !wr.TryGetTarget(out var target) || target == this);
|
||||
if (group.Count == 0)
|
||||
{
|
||||
_groups.Remove(_groupName);
|
||||
}
|
||||
if (group.Count == 0) _groups.Remove(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
private void UncheckOthersInGroup()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_groupName)) return;
|
||||
if (string.IsNullOrEmpty(GroupName)) return;
|
||||
|
||||
if (_groups.TryGetValue(_groupName, out var group))
|
||||
if (_groups.TryGetValue(GroupName, out var group))
|
||||
{
|
||||
foreach (var weakRef in group)
|
||||
{
|
||||
if (weakRef.TryGetTarget(out var radioButton) && radioButton != this)
|
||||
if (weakRef.TryGetTarget(out var radioButton) && radioButton != this && radioButton.IsChecked)
|
||||
{
|
||||
radioButton._isChecked = false;
|
||||
radioButton.CheckedChanged?.Invoke(radioButton, EventArgs.Empty);
|
||||
radioButton.Invalidate();
|
||||
radioButton.SetValue(IsCheckedProperty, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -133,18 +200,16 @@ public class SkiaRadioButton : SkiaView
|
|||
var radioCenterX = bounds.Left + radioRadius;
|
||||
var radioCenterY = bounds.MidY;
|
||||
|
||||
// Draw outer circle
|
||||
using var outerPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? (_isChecked ? RadioColor : UncheckedColor) : DisabledColor,
|
||||
Color = IsEnabled ? (IsChecked ? RadioColor : UncheckedColor) : DisabledColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius - 1, outerPaint);
|
||||
|
||||
// Draw inner circle if checked
|
||||
if (_isChecked)
|
||||
if (IsChecked)
|
||||
{
|
||||
using var innerPaint = new SKPaint
|
||||
{
|
||||
|
|
@ -155,7 +220,6 @@ public class SkiaRadioButton : SkiaView
|
|||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius - 5, innerPaint);
|
||||
}
|
||||
|
||||
// Draw focus ring
|
||||
if (IsFocused)
|
||||
{
|
||||
using var focusPaint = new SKPaint
|
||||
|
|
@ -167,8 +231,7 @@ public class SkiaRadioButton : SkiaView
|
|||
canvas.DrawCircle(radioCenterX, radioCenterY, radioRadius + 4, focusPaint);
|
||||
}
|
||||
|
||||
// Draw content text
|
||||
if (!string.IsNullOrEmpty(_content))
|
||||
if (!string.IsNullOrEmpty(Content))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
|
|
@ -179,48 +242,43 @@ public class SkiaRadioButton : SkiaView
|
|||
|
||||
var textX = bounds.Left + RadioSize + Spacing;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(_content, ref textBounds);
|
||||
canvas.DrawText(_content, textX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
textPaint.MeasureText(Content, ref textBounds);
|
||||
canvas.DrawText(Content, textX, bounds.MidY - textBounds.MidY, textPaint);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (!_isChecked)
|
||||
{
|
||||
IsChecked = true;
|
||||
}
|
||||
if (!IsChecked) IsChecked = true;
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
if (e.Key == Key.Space || e.Key == Key.Enter)
|
||||
{
|
||||
case Key.Space:
|
||||
case Key.Enter:
|
||||
if (!_isChecked)
|
||||
{
|
||||
IsChecked = true;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
if (!IsChecked) IsChecked = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var textWidth = 0f;
|
||||
if (!string.IsNullOrEmpty(_content))
|
||||
if (!string.IsNullOrEmpty(Content))
|
||||
{
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var paint = new SKPaint(font);
|
||||
textWidth = paint.MeasureText(_content) + Spacing;
|
||||
textWidth = paint.MeasureText(Content) + Spacing;
|
||||
}
|
||||
|
||||
return new SKSize(RadioSize + textWidth, Math.Max(RadioSize, FontSize * 1.5f));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,132 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered scroll view container.
|
||||
/// Skia-rendered scroll view container with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaScrollView : SkiaView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Orientation.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OrientationProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Orientation),
|
||||
typeof(ScrollOrientation),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollOrientation.Both,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalScrollBarVisibility.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalScrollBarVisibilityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalScrollBarVisibility),
|
||||
typeof(ScrollBarVisibility),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollBarVisibility.Auto,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalScrollBarVisibility.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalScrollBarVisibilityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalScrollBarVisibility),
|
||||
typeof(ScrollBarVisibility),
|
||||
typeof(SkiaScrollView),
|
||||
ScrollBarVisibility.Auto,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ScrollBarColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ScrollBarColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ScrollBarColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaScrollView),
|
||||
new SKColor(0x80, 0x80, 0x80, 0x80),
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ScrollBarWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ScrollBarWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ScrollBarWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaScrollView),
|
||||
8f,
|
||||
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scroll orientation.
|
||||
/// </summary>
|
||||
public ScrollOrientation Orientation
|
||||
{
|
||||
get => (ScrollOrientation)GetValue(OrientationProperty);
|
||||
set => SetValue(OrientationProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show horizontal scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility HorizontalScrollBarVisibility
|
||||
{
|
||||
get => (ScrollBarVisibility)GetValue(HorizontalScrollBarVisibilityProperty);
|
||||
set => SetValue(HorizontalScrollBarVisibilityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show vertical scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility VerticalScrollBarVisibility
|
||||
{
|
||||
get => (ScrollBarVisibility)GetValue(VerticalScrollBarVisibilityProperty);
|
||||
set => SetValue(VerticalScrollBarVisibilityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar color.
|
||||
/// </summary>
|
||||
public SKColor ScrollBarColor
|
||||
{
|
||||
get => (SKColor)GetValue(ScrollBarColorProperty);
|
||||
set => SetValue(ScrollBarColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar width.
|
||||
/// </summary>
|
||||
public float ScrollBarWidth
|
||||
{
|
||||
get => (float)GetValue(ScrollBarWidthProperty);
|
||||
set => SetValue(ScrollBarWidthProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private SkiaView? _content;
|
||||
private float _scrollX;
|
||||
private float _scrollY;
|
||||
private float _velocityX;
|
||||
private float _velocityY;
|
||||
private bool _isDragging;
|
||||
private bool _isDraggingVerticalScrollbar;
|
||||
private bool _isDraggingHorizontalScrollbar;
|
||||
private float _scrollbarDragStartY;
|
||||
private float _scrollbarDragStartScrollY;
|
||||
private float _scrollbarDragStartX;
|
||||
private float _scrollbarDragStartScrollX;
|
||||
private float _scrollbarDragAvailableTrack; // Cache to prevent stutter
|
||||
private float _scrollbarDragScrollableExtent; // Cache to prevent stutter
|
||||
private float _lastPointerX;
|
||||
private float _lastPointerY;
|
||||
|
||||
|
|
@ -35,14 +151,36 @@ public class SkiaScrollView : SkiaView
|
|||
_content = value;
|
||||
|
||||
if (_content != null)
|
||||
{
|
||||
_content.Parent = this;
|
||||
|
||||
// Propagate binding context to new content
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(_content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to content.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to content
|
||||
if (_content != null)
|
||||
{
|
||||
SetInheritedBindingContext(_content, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal scroll position.
|
||||
/// </summary>
|
||||
|
|
@ -82,43 +220,39 @@ public class SkiaScrollView : SkiaView
|
|||
/// <summary>
|
||||
/// Gets the maximum horizontal scroll extent.
|
||||
/// </summary>
|
||||
public float ScrollableWidth => Math.Max(0, ContentSize.Width - Bounds.Width);
|
||||
public float ScrollableWidth
|
||||
{
|
||||
get
|
||||
{
|
||||
// Handle infinite or NaN bounds - use a reasonable default viewport
|
||||
var viewportWidth = float.IsInfinity(Bounds.Width) || float.IsNaN(Bounds.Width) || Bounds.Width <= 0
|
||||
? 800f
|
||||
: Bounds.Width;
|
||||
return Math.Max(0, ContentSize.Width - viewportWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum vertical scroll extent.
|
||||
/// </summary>
|
||||
public float ScrollableHeight => Math.Max(0, ContentSize.Height - Bounds.Height);
|
||||
public float ScrollableHeight
|
||||
{
|
||||
get
|
||||
{
|
||||
// Handle infinite, NaN, or unreasonably large bounds - use a reasonable default viewport
|
||||
var boundsHeight = Bounds.Height;
|
||||
var viewportHeight = (float.IsInfinity(boundsHeight) || float.IsNaN(boundsHeight) || boundsHeight <= 0 || boundsHeight > 10000)
|
||||
? 544f // Default viewport height (600 - 56 for shell header)
|
||||
: boundsHeight;
|
||||
return Math.Max(0, ContentSize.Height - viewportHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content size.
|
||||
/// </summary>
|
||||
public SKSize ContentSize { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the scroll orientation.
|
||||
/// </summary>
|
||||
public ScrollOrientation Orientation { get; set; } = ScrollOrientation.Both;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show horizontal scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility HorizontalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to show vertical scrollbar.
|
||||
/// </summary>
|
||||
public ScrollBarVisibility VerticalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar color.
|
||||
/// </summary>
|
||||
public SKColor ScrollBarColor { get; set; } = new SKColor(0x80, 0x80, 0x80, 0x80);
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar width.
|
||||
/// </summary>
|
||||
public float ScrollBarWidth { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when scroll position changes.
|
||||
/// </summary>
|
||||
|
|
@ -133,6 +267,27 @@ public class SkiaScrollView : SkiaView
|
|||
// Draw content with scroll offset
|
||||
if (_content != null)
|
||||
{
|
||||
// Ensure content is measured and arranged
|
||||
// Account for vertical scrollbar width to prevent horizontal scrollbar from appearing
|
||||
var effectiveWidth = bounds.Width;
|
||||
if (Orientation != ScrollOrientation.Horizontal && VerticalScrollBarVisibility != ScrollBarVisibility.Never)
|
||||
{
|
||||
// Reserve space for vertical scrollbar if content might be taller than viewport
|
||||
effectiveWidth -= ScrollBarWidth;
|
||||
}
|
||||
var availableSize = new SKSize(effectiveWidth, float.PositiveInfinity);
|
||||
// Update ContentSize with the properly constrained measurement
|
||||
ContentSize = _content.Measure(availableSize);
|
||||
|
||||
// Apply content's margin
|
||||
var margin = _content.Margin;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left + (float)margin.Left,
|
||||
bounds.Top + (float)margin.Top,
|
||||
bounds.Left + Math.Max(bounds.Width, _content.DesiredSize.Width) - (float)margin.Right,
|
||||
bounds.Top + Math.Max(bounds.Height, _content.DesiredSize.Height) - (float)margin.Bottom);
|
||||
_content.Arrange(contentBounds);
|
||||
|
||||
canvas.Save();
|
||||
canvas.Translate(-_scrollX, -_scrollY);
|
||||
_content.Draw(canvas);
|
||||
|
|
@ -233,22 +388,89 @@ public class SkiaScrollView : SkiaView
|
|||
|
||||
public override void OnScroll(ScrollEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[SkiaScrollView] OnScroll - DeltaY={e.DeltaY}, ScrollableHeight={ScrollableHeight}, ContentSize={ContentSize}, Bounds={Bounds}");
|
||||
|
||||
// Handle mouse wheel scrolling
|
||||
var deltaMultiplier = 40f; // Scroll speed
|
||||
bool scrolled = false;
|
||||
|
||||
if (Orientation != ScrollOrientation.Horizontal)
|
||||
if (Orientation != ScrollOrientation.Horizontal && ScrollableHeight > 0)
|
||||
{
|
||||
var oldScrollY = _scrollY;
|
||||
ScrollY += e.DeltaY * deltaMultiplier;
|
||||
Console.WriteLine($"[SkiaScrollView] ScrollY changed: {oldScrollY} -> {_scrollY}");
|
||||
if (_scrollY != oldScrollY)
|
||||
scrolled = true;
|
||||
}
|
||||
|
||||
if (Orientation != ScrollOrientation.Vertical)
|
||||
if (Orientation != ScrollOrientation.Vertical && ScrollableWidth > 0)
|
||||
{
|
||||
var oldScrollX = _scrollX;
|
||||
ScrollX += e.DeltaX * deltaMultiplier;
|
||||
if (_scrollX != oldScrollX)
|
||||
scrolled = true;
|
||||
}
|
||||
|
||||
// Mark as handled so parent scroll views don't also scroll
|
||||
if (scrolled)
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
// Check if clicking on vertical scrollbar thumb
|
||||
if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0)
|
||||
{
|
||||
var thumbBounds = GetVerticalScrollbarThumbBounds();
|
||||
if (thumbBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isDraggingVerticalScrollbar = true;
|
||||
_scrollbarDragStartY = e.Y;
|
||||
_scrollbarDragStartScrollY = _scrollY;
|
||||
// Cache values to prevent stutter from floating-point recalculations
|
||||
var hasHorizontal = ShouldShowHorizontalScrollbar();
|
||||
var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0);
|
||||
var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight);
|
||||
_scrollbarDragAvailableTrack = trackHeight - thumbHeight;
|
||||
_scrollbarDragScrollableExtent = ScrollableHeight;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if clicking on horizontal scrollbar thumb
|
||||
if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0)
|
||||
{
|
||||
var thumbBounds = GetHorizontalScrollbarThumbBounds();
|
||||
if (thumbBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isDraggingHorizontalScrollbar = true;
|
||||
_scrollbarDragStartX = e.X;
|
||||
_scrollbarDragStartScrollX = _scrollX;
|
||||
// Cache values to prevent stutter from floating-point recalculations
|
||||
var hasVertical = ShouldShowVerticalScrollbar();
|
||||
var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0);
|
||||
var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth);
|
||||
_scrollbarDragAvailableTrack = trackWidth - thumbWidth;
|
||||
_scrollbarDragScrollableExtent = ScrollableWidth;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Forward click to content first
|
||||
if (_content != null)
|
||||
{
|
||||
// Translate coordinates for scroll offset
|
||||
var contentE = new PointerEventArgs(e.X + _scrollX, e.Y + _scrollY, e.Button);
|
||||
var hit = _content.HitTest(contentE.X, contentE.Y);
|
||||
if (hit != null && hit != _content)
|
||||
{
|
||||
// A child view was hit - forward the event to it
|
||||
hit.OnPointerPressed(contentE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular content dragging
|
||||
_isDragging = true;
|
||||
_lastPointerX = e.X;
|
||||
_lastPointerY = e.Y;
|
||||
|
|
@ -258,19 +480,44 @@ public class SkiaScrollView : SkiaView
|
|||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
// Handle vertical scrollbar dragging - use cached values to prevent stutter
|
||||
if (_isDraggingVerticalScrollbar)
|
||||
{
|
||||
if (_scrollbarDragAvailableTrack > 0)
|
||||
{
|
||||
var deltaY = e.Y - _scrollbarDragStartY;
|
||||
var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent;
|
||||
ScrollY = _scrollbarDragStartScrollY + scrollDelta;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle horizontal scrollbar dragging - use cached values to prevent stutter
|
||||
if (_isDraggingHorizontalScrollbar)
|
||||
{
|
||||
if (_scrollbarDragAvailableTrack > 0)
|
||||
{
|
||||
var deltaX = e.X - _scrollbarDragStartX;
|
||||
var scrollDelta = (deltaX / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent;
|
||||
ScrollX = _scrollbarDragStartScrollX + scrollDelta;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle content dragging
|
||||
if (!_isDragging) return;
|
||||
|
||||
var deltaX = _lastPointerX - e.X;
|
||||
var deltaY = _lastPointerY - e.Y;
|
||||
var contentDeltaX = _lastPointerX - e.X;
|
||||
var contentDeltaY = _lastPointerY - e.Y;
|
||||
|
||||
_velocityX = deltaX;
|
||||
_velocityY = deltaY;
|
||||
_velocityX = contentDeltaX;
|
||||
_velocityY = contentDeltaY;
|
||||
|
||||
if (Orientation != ScrollOrientation.Horizontal)
|
||||
ScrollY += deltaY;
|
||||
ScrollY += contentDeltaY;
|
||||
|
||||
if (Orientation != ScrollOrientation.Vertical)
|
||||
ScrollX += deltaX;
|
||||
ScrollX += contentDeltaX;
|
||||
|
||||
_lastPointerX = e.X;
|
||||
_lastPointerY = e.Y;
|
||||
|
|
@ -279,14 +526,62 @@ public class SkiaScrollView : SkiaView
|
|||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
_isDragging = false;
|
||||
_isDraggingVerticalScrollbar = false;
|
||||
_isDraggingHorizontalScrollbar = false;
|
||||
// Momentum scrolling could be added here
|
||||
}
|
||||
|
||||
private SKRect GetVerticalScrollbarThumbBounds()
|
||||
{
|
||||
var hasHorizontal = ShouldShowHorizontalScrollbar();
|
||||
var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0);
|
||||
var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight);
|
||||
var thumbY = ScrollableHeight > 0 ? (ScrollY / ScrollableHeight) * (trackHeight - thumbHeight) : 0;
|
||||
|
||||
return new SKRect(
|
||||
Bounds.Right - ScrollBarWidth,
|
||||
Bounds.Top + thumbY,
|
||||
Bounds.Right,
|
||||
Bounds.Top + thumbY + thumbHeight);
|
||||
}
|
||||
|
||||
private SKRect GetHorizontalScrollbarThumbBounds()
|
||||
{
|
||||
var hasVertical = ShouldShowVerticalScrollbar();
|
||||
var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0);
|
||||
var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth);
|
||||
var thumbX = ScrollableWidth > 0 ? (ScrollX / ScrollableWidth) * (trackWidth - thumbWidth) : 0;
|
||||
|
||||
return new SKRect(
|
||||
Bounds.Left + thumbX,
|
||||
Bounds.Bottom - ScrollBarWidth,
|
||||
Bounds.Left + thumbX + thumbWidth,
|
||||
Bounds.Bottom);
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
|
||||
if (!IsVisible || !IsEnabled || !Bounds.Contains(new SKPoint(x, y)))
|
||||
return null;
|
||||
|
||||
// Check scrollbar areas FIRST before content
|
||||
// This ensures scrollbar clicks are handled by the ScrollView, not content underneath
|
||||
if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0)
|
||||
{
|
||||
var thumbBounds = GetVerticalScrollbarThumbBounds();
|
||||
// Check if click is in the scrollbar track area (not just thumb)
|
||||
var trackArea = new SKRect(Bounds.Right - ScrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom);
|
||||
if (trackArea.Contains(x, y))
|
||||
return this;
|
||||
}
|
||||
|
||||
if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0)
|
||||
{
|
||||
var trackArea = new SKRect(Bounds.Left, Bounds.Bottom - ScrollBarWidth, Bounds.Right, Bounds.Bottom);
|
||||
if (trackArea.Contains(x, y))
|
||||
return this;
|
||||
}
|
||||
|
||||
// Hit test content with scroll offset
|
||||
if (_content != null)
|
||||
{
|
||||
|
|
@ -360,35 +655,94 @@ public class SkiaScrollView : SkiaView
|
|||
{
|
||||
if (_content != null)
|
||||
{
|
||||
// Give content unlimited size in scrollable directions
|
||||
var contentAvailable = new SKSize(
|
||||
Orientation == ScrollOrientation.Vertical ? availableSize.Width : float.PositiveInfinity,
|
||||
Orientation == ScrollOrientation.Horizontal ? availableSize.Height : float.PositiveInfinity);
|
||||
// For responsive layout:
|
||||
// - Vertical: give content viewport width, infinite height
|
||||
// - Horizontal: give content infinite width, viewport height
|
||||
// - Both: give content viewport width first (for responsive layout),
|
||||
// but if content exceeds it, horizontal scrollbar appears
|
||||
// - Neither: give content exact viewport size
|
||||
|
||||
ContentSize = _content.Measure(contentAvailable);
|
||||
float contentWidth, contentHeight;
|
||||
|
||||
switch (Orientation)
|
||||
{
|
||||
case ScrollOrientation.Horizontal:
|
||||
contentWidth = float.PositiveInfinity;
|
||||
contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height;
|
||||
break;
|
||||
case ScrollOrientation.Neither:
|
||||
contentWidth = float.IsInfinity(availableSize.Width) ? 400f : availableSize.Width;
|
||||
contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height;
|
||||
break;
|
||||
case ScrollOrientation.Both:
|
||||
// For Both: first measure with viewport width to get responsive layout
|
||||
// Content can still exceed viewport if it has minimum width constraints
|
||||
// Reserve space for vertical scrollbar to prevent horizontal scrollbar
|
||||
contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width;
|
||||
if (VerticalScrollBarVisibility != ScrollBarVisibility.Never)
|
||||
contentWidth -= ScrollBarWidth;
|
||||
contentHeight = float.PositiveInfinity;
|
||||
break;
|
||||
case ScrollOrientation.Vertical:
|
||||
default:
|
||||
// Reserve space for vertical scrollbar to prevent horizontal scrollbar
|
||||
contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width;
|
||||
if (VerticalScrollBarVisibility != ScrollBarVisibility.Never)
|
||||
contentWidth -= ScrollBarWidth;
|
||||
contentHeight = float.PositiveInfinity;
|
||||
break;
|
||||
}
|
||||
|
||||
ContentSize = _content.Measure(new SKSize(contentWidth, contentHeight));
|
||||
}
|
||||
else
|
||||
{
|
||||
ContentSize = SKSize.Empty;
|
||||
}
|
||||
|
||||
return availableSize;
|
||||
// Return available size, but clamp infinite dimensions
|
||||
// IMPORTANT: When available is infinite, return a reasonable viewport size, NOT content size
|
||||
// A ScrollView should NOT expand to fit its content - it should stay at a fixed viewport
|
||||
// and scroll the content. Use a default viewport size when parent gives infinity.
|
||||
const float DefaultViewportWidth = 400f;
|
||||
const float DefaultViewportHeight = 400f;
|
||||
|
||||
var width = float.IsInfinity(availableSize.Width) || float.IsNaN(availableSize.Width)
|
||||
? Math.Min(ContentSize.Width, DefaultViewportWidth)
|
||||
: availableSize.Width;
|
||||
var height = float.IsInfinity(availableSize.Height) || float.IsNaN(availableSize.Height)
|
||||
? Math.Min(ContentSize.Height, DefaultViewportHeight)
|
||||
: availableSize.Height;
|
||||
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
|
||||
// CRITICAL: If bounds has infinite height, use a fixed viewport size
|
||||
// NOT ContentSize.Height - that would make ScrollableHeight = 0
|
||||
const float DefaultViewportHeight = 544f; // 600 - 56 for shell header
|
||||
var actualBounds = bounds;
|
||||
if (float.IsInfinity(bounds.Height) || float.IsNaN(bounds.Height))
|
||||
{
|
||||
Console.WriteLine($"[SkiaScrollView] WARNING: Infinite/NaN height, using default viewport={DefaultViewportHeight}");
|
||||
actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + DefaultViewportHeight);
|
||||
}
|
||||
|
||||
if (_content != null)
|
||||
{
|
||||
// Arrange content at its full size, starting from scroll position
|
||||
// Apply content's margin and arrange content at its full size
|
||||
var margin = _content.Margin;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Top,
|
||||
bounds.Left + Math.Max(bounds.Width, ContentSize.Width),
|
||||
bounds.Top + Math.Max(bounds.Height, ContentSize.Height));
|
||||
actualBounds.Left + (float)margin.Left,
|
||||
actualBounds.Top + (float)margin.Top,
|
||||
actualBounds.Left + Math.Max(actualBounds.Width, ContentSize.Width) - (float)margin.Right,
|
||||
actualBounds.Top + Math.Max(actualBounds.Height, ContentSize.Height) - (float)margin.Bottom);
|
||||
|
||||
_content.Arrange(contentBounds);
|
||||
}
|
||||
return bounds;
|
||||
return actualBounds;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,9 +55,11 @@ public class SkiaSearchBar : SkiaView
|
|||
_entry = new SkiaEntry
|
||||
{
|
||||
Placeholder = "Search...",
|
||||
EntryBackgroundColor = SKColors.Transparent,
|
||||
BackgroundColor = SKColors.Transparent,
|
||||
BorderColor = SKColors.Transparent,
|
||||
FocusedBorderColor = SKColors.Transparent
|
||||
FocusedBorderColor = SKColors.Transparent,
|
||||
BorderWidth = 0
|
||||
};
|
||||
|
||||
_entry.TextChanged += (s, e) =>
|
||||
|
|
@ -193,12 +195,24 @@ public class SkiaSearchBar : SkiaView
|
|||
return;
|
||||
}
|
||||
|
||||
// Forward to entry for text input focus
|
||||
// Forward to entry for text input focus and selection
|
||||
_entry.IsFocused = true;
|
||||
IsFocused = true;
|
||||
_entry.OnPointerPressed(e);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
_entry.OnPointerMoved(e);
|
||||
}
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
_entry.OnPointerReleased(e);
|
||||
}
|
||||
|
||||
public override void OnTextInput(TextInputEventArgs e)
|
||||
{
|
||||
_entry.OnTextInput(e);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ public class SkiaShell : SkiaLayoutView
|
|||
private int _selectedSectionIndex = 0;
|
||||
private int _selectedItemIndex = 0;
|
||||
|
||||
// Navigation stack for push/pop navigation
|
||||
private readonly Stack<(SkiaView Content, string Title)> _navigationStack = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the flyout is presented.
|
||||
/// </summary>
|
||||
|
|
@ -93,6 +96,12 @@ public class SkiaShell : SkiaLayoutView
|
|||
/// </summary>
|
||||
public bool TabBarIsVisible { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the padding applied to page content.
|
||||
/// Default is 16 pixels on all sides.
|
||||
/// </summary>
|
||||
public float ContentPadding { get; set; } = 16f;
|
||||
|
||||
/// <summary>
|
||||
/// Current title displayed in the navigation bar.
|
||||
/// </summary>
|
||||
|
|
@ -103,6 +112,11 @@ public class SkiaShell : SkiaLayoutView
|
|||
/// </summary>
|
||||
public IReadOnlyList<ShellSection> Sections => _sections;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently selected section index.
|
||||
/// </summary>
|
||||
public int CurrentSectionIndex => _selectedSectionIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when FlyoutIsPresented changes.
|
||||
/// </summary>
|
||||
|
|
@ -147,6 +161,9 @@ public class SkiaShell : SkiaLayoutView
|
|||
var section = _sections[sectionIndex];
|
||||
if (itemIndex < 0 || itemIndex >= section.Items.Count) return;
|
||||
|
||||
// Clear navigation stack when navigating to a new section
|
||||
_navigationStack.Clear();
|
||||
|
||||
_selectedSectionIndex = sectionIndex;
|
||||
_selectedItemIndex = itemIndex;
|
||||
|
||||
|
|
@ -193,6 +210,66 @@ public class SkiaShell : SkiaLayoutView
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are pages on the navigation stack.
|
||||
/// </summary>
|
||||
public bool CanGoBack => _navigationStack.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current navigation stack depth.
|
||||
/// </summary>
|
||||
public int NavigationStackDepth => _navigationStack.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a new page onto the navigation stack.
|
||||
/// </summary>
|
||||
public void PushAsync(SkiaView page, string title)
|
||||
{
|
||||
// Save current content to stack
|
||||
if (_currentContent != null)
|
||||
{
|
||||
_navigationStack.Push((_currentContent, Title));
|
||||
}
|
||||
|
||||
// Set new content
|
||||
SetCurrentContent(page);
|
||||
Title = title;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops the current page from the navigation stack.
|
||||
/// </summary>
|
||||
public bool PopAsync()
|
||||
{
|
||||
if (_navigationStack.Count == 0) return false;
|
||||
|
||||
var (previousContent, previousTitle) = _navigationStack.Pop();
|
||||
SetCurrentContent(previousContent);
|
||||
Title = previousTitle;
|
||||
Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pops all pages from the navigation stack, returning to the root.
|
||||
/// </summary>
|
||||
public void PopToRootAsync()
|
||||
{
|
||||
if (_navigationStack.Count == 0) return;
|
||||
|
||||
// Get the root content
|
||||
(SkiaView Content, string Title) root = default;
|
||||
while (_navigationStack.Count > 0)
|
||||
{
|
||||
root = _navigationStack.Pop();
|
||||
}
|
||||
|
||||
SetCurrentContent(root.Content);
|
||||
Title = root.Title;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void SetCurrentContent(SkiaView? content)
|
||||
{
|
||||
if (_currentContent != null)
|
||||
|
|
@ -210,14 +287,14 @@ public class SkiaShell : SkiaLayoutView
|
|||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
// Measure current content
|
||||
// Measure current content with padding accounted for (consistent with ArrangeOverride)
|
||||
if (_currentContent != null)
|
||||
{
|
||||
float contentTop = NavBarIsVisible ? NavBarHeight : 0;
|
||||
float contentBottom = TabBarIsVisible ? TabBarHeight : 0;
|
||||
var contentSize = new SKSize(
|
||||
availableSize.Width,
|
||||
availableSize.Height - contentTop - contentBottom);
|
||||
availableSize.Width - (float)Padding.Left - (float)Padding.Right,
|
||||
availableSize.Height - contentTop - contentBottom - (float)Padding.Top - (float)Padding.Bottom);
|
||||
_currentContent.Measure(contentSize);
|
||||
}
|
||||
|
||||
|
|
@ -226,16 +303,19 @@ public class SkiaShell : SkiaLayoutView
|
|||
|
||||
protected override SKRect ArrangeOverride(SKRect bounds)
|
||||
{
|
||||
// Arrange current content
|
||||
Console.WriteLine($"[SkiaShell] ArrangeOverride - bounds={bounds}");
|
||||
|
||||
// Arrange current content with padding
|
||||
if (_currentContent != null)
|
||||
{
|
||||
float contentTop = bounds.Top + (NavBarIsVisible ? NavBarHeight : 0);
|
||||
float contentBottom = bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0);
|
||||
float contentTop = bounds.Top + (NavBarIsVisible ? NavBarHeight : 0) + ContentPadding;
|
||||
float contentBottom = bounds.Bottom - (TabBarIsVisible ? TabBarHeight : 0) - ContentPadding;
|
||||
var contentBounds = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Left + ContentPadding,
|
||||
contentTop,
|
||||
bounds.Right,
|
||||
bounds.Right - ContentPadding,
|
||||
contentBottom);
|
||||
Console.WriteLine($"[SkiaShell] Arranging content with bounds={contentBounds}, padding={ContentPadding}");
|
||||
_currentContent.Arrange(contentBounds);
|
||||
}
|
||||
|
||||
|
|
@ -288,20 +368,41 @@ public class SkiaShell : SkiaLayoutView
|
|||
};
|
||||
canvas.DrawRect(navBarBounds, bgPaint);
|
||||
|
||||
// Draw hamburger menu icon
|
||||
if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
// Draw nav icon (back arrow if can go back, else hamburger menu if flyout enabled)
|
||||
using var iconPaint = new SKPaint
|
||||
{
|
||||
using var iconPaint = new SKPaint
|
||||
Color = NavBarTextColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
StrokeCap = SKStrokeCap.Round,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float iconLeft = navBarBounds.Left + 16;
|
||||
float iconCenter = navBarBounds.MidY;
|
||||
|
||||
if (CanGoBack)
|
||||
{
|
||||
// Draw iOS-style back chevron "<"
|
||||
using var chevronPaint = new SKPaint
|
||||
{
|
||||
Color = NavBarTextColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
StrokeWidth = 2.5f,
|
||||
StrokeCap = SKStrokeCap.Round,
|
||||
StrokeJoin = SKStrokeJoin.Round,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
float iconLeft = navBarBounds.Left + 16;
|
||||
float iconCenter = navBarBounds.MidY;
|
||||
|
||||
// Clean chevron pointing left
|
||||
float chevronX = iconLeft + 6;
|
||||
float chevronSize = 10;
|
||||
canvas.DrawLine(chevronX + chevronSize, iconCenter - chevronSize, chevronX, iconCenter, chevronPaint);
|
||||
canvas.DrawLine(chevronX, iconCenter, chevronX + chevronSize, iconCenter + chevronSize, chevronPaint);
|
||||
}
|
||||
else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
{
|
||||
// Draw hamburger menu icon
|
||||
canvas.DrawLine(iconLeft, iconCenter - 8, iconLeft + 18, iconCenter - 8, iconPaint);
|
||||
canvas.DrawLine(iconLeft, iconCenter, iconLeft + 18, iconCenter, iconPaint);
|
||||
canvas.DrawLine(iconLeft, iconCenter + 8, iconLeft + 18, iconCenter + 8, iconPaint);
|
||||
|
|
@ -316,7 +417,7 @@ public class SkiaShell : SkiaLayoutView
|
|||
FakeBoldText = true
|
||||
};
|
||||
|
||||
float titleX = FlyoutBehavior == ShellFlyoutBehavior.Flyout ? navBarBounds.Left + 56 : navBarBounds.Left + 16;
|
||||
float titleX = (CanGoBack || FlyoutBehavior == ShellFlyoutBehavior.Flyout) ? navBarBounds.Left + 56 : navBarBounds.Left + 16;
|
||||
float titleY = navBarBounds.MidY + 6;
|
||||
canvas.DrawText(Title, titleX, titleY, titlePaint);
|
||||
}
|
||||
|
|
@ -427,7 +528,8 @@ public class SkiaShell : SkiaLayoutView
|
|||
Color = new SKColor(33, 150, 243, 30),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
canvas.DrawRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight, selectionPaint);
|
||||
var selectionRect = new SKRect(flyoutBounds.Left, itemY, flyoutBounds.Right, itemY + itemHeight);
|
||||
canvas.DrawRect(selectionRect, selectionPaint);
|
||||
}
|
||||
|
||||
itemTextPaint.Color = isSelected ? NavBarBackgroundColor : new SKColor(33, 33, 33);
|
||||
|
|
@ -518,12 +620,23 @@ public class SkiaShell : SkiaLayoutView
|
|||
}
|
||||
}
|
||||
|
||||
// Check nav bar hamburger tap
|
||||
if (NavBarIsVisible && e.Y < Bounds.Top + NavBarHeight && e.X < 56 && FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
// Check nav bar icon tap (back button or hamburger menu)
|
||||
if (NavBarIsVisible && e.Y < Bounds.Top + NavBarHeight && e.X < 56)
|
||||
{
|
||||
FlyoutIsPresented = !FlyoutIsPresented;
|
||||
e.Handled = true;
|
||||
return;
|
||||
if (CanGoBack)
|
||||
{
|
||||
// Back button pressed
|
||||
PopAsync();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
else if (FlyoutBehavior == ShellFlyoutBehavior.Flyout)
|
||||
{
|
||||
// Hamburger menu pressed
|
||||
FlyoutIsPresented = !FlyoutIsPresented;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tab bar tap
|
||||
|
|
|
|||
|
|
@ -6,40 +6,214 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered slider control.
|
||||
/// Skia-rendered slider control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaSlider : SkiaView
|
||||
{
|
||||
private bool _isDragging;
|
||||
private double _value;
|
||||
#region BindableProperties
|
||||
|
||||
public double Minimum { get; set; } = 0;
|
||||
public double Maximum { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// Bindable property for Minimum.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Minimum),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged());
|
||||
|
||||
public double Value
|
||||
/// <summary>
|
||||
/// Bindable property for Maximum.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MaximumProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Maximum),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
100.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnRangeChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Value.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Value),
|
||||
typeof(double),
|
||||
typeof(SkiaSlider),
|
||||
0.0,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).OnValuePropertyChanged((double)o, (double)n));
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ActiveTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ActiveTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ActiveTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSlider),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaSlider),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaSlider),
|
||||
10f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSlider)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum value.
|
||||
/// </summary>
|
||||
public double Minimum
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, Minimum, Maximum);
|
||||
if (_value != clamped)
|
||||
{
|
||||
_value = clamped;
|
||||
ValueChanged?.Invoke(this, new SliderValueChangedEventArgs(_value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(MinimumProperty);
|
||||
set => SetValue(MinimumProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TrackColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ActiveTrackColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor ThumbColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float TrackHeight { get; set; } = 4;
|
||||
public float ThumbRadius { get; set; } = 10;
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum value.
|
||||
/// </summary>
|
||||
public double Maximum
|
||||
{
|
||||
get => (double)GetValue(MaximumProperty);
|
||||
set => SetValue(MaximumProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current value.
|
||||
/// </summary>
|
||||
public double Value
|
||||
{
|
||||
get => (double)GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, Math.Clamp(value, Minimum, Maximum));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track color.
|
||||
/// </summary>
|
||||
public SKColor TrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(TrackColorProperty);
|
||||
set => SetValue(TrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the active track color.
|
||||
/// </summary>
|
||||
public SKColor ActiveTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(ActiveTrackColorProperty);
|
||||
set => SetValue(ActiveTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb color.
|
||||
/// </summary>
|
||||
public SKColor ThumbColor
|
||||
{
|
||||
get => (SKColor)GetValue(ThumbColorProperty);
|
||||
set => SetValue(ThumbColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track height.
|
||||
/// </summary>
|
||||
public float TrackHeight
|
||||
{
|
||||
get => (float)GetValue(TrackHeightProperty);
|
||||
set => SetValue(TrackHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb radius.
|
||||
/// </summary>
|
||||
public float ThumbRadius
|
||||
{
|
||||
get => (float)GetValue(ThumbRadiusProperty);
|
||||
set => SetValue(ThumbRadiusProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isDragging;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the value changes.
|
||||
/// </summary>
|
||||
public event EventHandler<SliderValueChangedEventArgs>? ValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when drag starts.
|
||||
/// </summary>
|
||||
public event EventHandler? DragStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when drag completes.
|
||||
/// </summary>
|
||||
public event EventHandler? DragCompleted;
|
||||
|
||||
public SkiaSlider()
|
||||
|
|
@ -47,6 +221,23 @@ public class SkiaSlider : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnRangeChanged()
|
||||
{
|
||||
// Clamp value to new range
|
||||
var clamped = Math.Clamp(Value, Minimum, Maximum);
|
||||
if (Value != clamped)
|
||||
{
|
||||
Value = clamped;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnValuePropertyChanged(double oldValue, double newValue)
|
||||
{
|
||||
ValueChanged?.Invoke(this, new SliderValueChangedEventArgs(newValue));
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var trackY = bounds.MidY;
|
||||
|
|
@ -54,7 +245,7 @@ public class SkiaSlider : SkiaView
|
|||
var trackRight = bounds.Right - ThumbRadius;
|
||||
var trackWidth = trackRight - trackLeft;
|
||||
|
||||
var percentage = (Value - Minimum) / (Maximum - Minimum);
|
||||
var percentage = Maximum > Minimum ? (Value - Minimum) / (Maximum - Minimum) : 0;
|
||||
var thumbX = trackLeft + (float)(percentage * trackWidth);
|
||||
|
||||
// Draw inactive track
|
||||
|
|
@ -127,6 +318,7 @@ public class SkiaSlider : SkiaView
|
|||
_isDragging = true;
|
||||
UpdateValueFromPosition(e.X);
|
||||
DragStarted?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed);
|
||||
}
|
||||
|
||||
public override void OnPointerMoved(PointerEventArgs e)
|
||||
|
|
@ -141,6 +333,7 @@ public class SkiaSlider : SkiaView
|
|||
{
|
||||
_isDragging = false;
|
||||
DragCompleted?.Invoke(this, EventArgs.Empty);
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,12 +376,21 @@ public class SkiaSlider : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(200, ThumbRadius * 2 + 16);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for slider value changed events.
|
||||
/// </summary>
|
||||
public class SliderValueChangedEventArgs : EventArgs
|
||||
{
|
||||
public double NewValue { get; }
|
||||
|
|
|
|||
|
|
@ -10,66 +10,136 @@ namespace Microsoft.Maui.Platform;
|
|||
/// </summary>
|
||||
public class SkiaStepper : SkiaView
|
||||
{
|
||||
private double _value;
|
||||
private double _minimum;
|
||||
private double _maximum = 100;
|
||||
private double _increment = 1;
|
||||
private bool _isMinusPressed;
|
||||
private bool _isPlusPressed;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor ButtonBackgroundColor { get; set; } = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
public SKColor ButtonPressedColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor ButtonDisabledColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor SymbolColor { get; set; } = SKColors.Black;
|
||||
public SKColor SymbolDisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public float ButtonWidth { get; set; } = 40;
|
||||
public static readonly BindableProperty ValueProperty =
|
||||
BindableProperty.Create(nameof(Value), typeof(double), typeof(SkiaStepper), 0.0, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnValuePropertyChanged((double)o, (double)n));
|
||||
|
||||
public static readonly BindableProperty MinimumProperty =
|
||||
BindableProperty.Create(nameof(Minimum), typeof(double), typeof(SkiaStepper), 0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
|
||||
|
||||
public static readonly BindableProperty MaximumProperty =
|
||||
BindableProperty.Create(nameof(Maximum), typeof(double), typeof(SkiaStepper), 100.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).OnRangeChanged());
|
||||
|
||||
public static readonly BindableProperty IncrementProperty =
|
||||
BindableProperty.Create(nameof(Increment), typeof(double), typeof(SkiaStepper), 1.0);
|
||||
|
||||
public static readonly BindableProperty ButtonBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonBackgroundColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xE0, 0xE0, 0xE0),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonPressedColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonPressedColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonDisabledColorProperty =
|
||||
BindableProperty.Create(nameof(ButtonDisabledColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xF5, 0xF5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SymbolColorProperty =
|
||||
BindableProperty.Create(nameof(SymbolColor), typeof(SKColor), typeof(SkiaStepper), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SymbolDisabledColorProperty =
|
||||
BindableProperty.Create(nameof(SymbolDisabledColor), typeof(SKColor), typeof(SkiaStepper), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaStepper), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ButtonWidthProperty =
|
||||
BindableProperty.Create(nameof(ButtonWidth), typeof(float), typeof(SkiaStepper), 40f,
|
||||
propertyChanged: (b, o, n) => ((SkiaStepper)b).InvalidateMeasure());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public double Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, _minimum, _maximum);
|
||||
if (_value != clamped)
|
||||
{
|
||||
_value = clamped;
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (double)GetValue(ValueProperty);
|
||||
set => SetValue(ValueProperty, Math.Clamp(value, Minimum, Maximum));
|
||||
}
|
||||
|
||||
public double Minimum
|
||||
{
|
||||
get => _minimum;
|
||||
set
|
||||
{
|
||||
_minimum = value;
|
||||
if (_value < _minimum) Value = _minimum;
|
||||
Invalidate();
|
||||
}
|
||||
get => (double)GetValue(MinimumProperty);
|
||||
set => SetValue(MinimumProperty, value);
|
||||
}
|
||||
|
||||
public double Maximum
|
||||
{
|
||||
get => _maximum;
|
||||
set
|
||||
{
|
||||
_maximum = value;
|
||||
if (_value > _maximum) Value = _maximum;
|
||||
Invalidate();
|
||||
}
|
||||
get => (double)GetValue(MaximumProperty);
|
||||
set => SetValue(MaximumProperty, value);
|
||||
}
|
||||
|
||||
public double Increment
|
||||
{
|
||||
get => _increment;
|
||||
set { _increment = Math.Max(0.001, value); Invalidate(); }
|
||||
get => (double)GetValue(IncrementProperty);
|
||||
set => SetValue(IncrementProperty, Math.Max(0.001, value));
|
||||
}
|
||||
|
||||
public SKColor ButtonBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonBackgroundColorProperty);
|
||||
set => SetValue(ButtonBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ButtonPressedColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonPressedColorProperty);
|
||||
set => SetValue(ButtonPressedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ButtonDisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(ButtonDisabledColorProperty);
|
||||
set => SetValue(ButtonDisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SymbolColor
|
||||
{
|
||||
get => (SKColor)GetValue(SymbolColorProperty);
|
||||
set => SetValue(SymbolColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SymbolDisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(SymbolDisabledColorProperty);
|
||||
set => SetValue(SymbolDisabledColorProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public float ButtonWidth
|
||||
{
|
||||
get => (float)GetValue(ButtonWidthProperty);
|
||||
set => SetValue(ButtonWidthProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isMinusPressed;
|
||||
private bool _isPlusPressed;
|
||||
|
||||
public event EventHandler? ValueChanged;
|
||||
|
||||
public SkiaStepper()
|
||||
|
|
@ -77,19 +147,30 @@ public class SkiaStepper : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnValuePropertyChanged(double oldValue, double newValue)
|
||||
{
|
||||
ValueChanged?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void OnRangeChanged()
|
||||
{
|
||||
var clamped = Math.Clamp(Value, Minimum, Maximum);
|
||||
if (Value != clamped)
|
||||
{
|
||||
Value = clamped;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var buttonHeight = bounds.Height;
|
||||
var minusRect = new SKRect(bounds.Left, bounds.Top, bounds.Left + ButtonWidth, bounds.Bottom);
|
||||
var plusRect = new SKRect(bounds.Right - ButtonWidth, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
|
||||
// Draw minus button
|
||||
DrawButton(canvas, minusRect, "-", _isMinusPressed, !CanDecrement());
|
||||
|
||||
// Draw plus button
|
||||
DrawButton(canvas, plusRect, "+", _isPlusPressed, !CanIncrement());
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
|
|
@ -98,29 +179,23 @@ public class SkiaStepper : SkiaView
|
|||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Overall border with rounded corners
|
||||
var totalRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Bottom);
|
||||
canvas.DrawRoundRect(new SKRoundRect(totalRect, CornerRadius), borderPaint);
|
||||
|
||||
// Center divider
|
||||
var centerX = bounds.MidX;
|
||||
canvas.DrawLine(centerX, bounds.Top, centerX, bounds.Bottom, borderPaint);
|
||||
}
|
||||
|
||||
private void DrawButton(SKCanvas canvas, SKRect rect, string symbol, bool isPressed, bool isDisabled)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = isDisabled ? ButtonDisabledColor : (isPressed ? ButtonPressedColor : ButtonBackgroundColor),
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
// Draw button background (clipped by overall border)
|
||||
canvas.DrawRect(rect, bgPaint);
|
||||
|
||||
// Draw symbol
|
||||
using var font = new SKFont(SKTypeface.Default, 20);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
|
|
@ -133,23 +208,22 @@ public class SkiaStepper : SkiaView
|
|||
canvas.DrawText(symbol, rect.MidX - textBounds.MidX, rect.MidY - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
private bool CanIncrement() => IsEnabled && _value < _maximum;
|
||||
private bool CanDecrement() => IsEnabled && _value > _minimum;
|
||||
private bool CanIncrement() => IsEnabled && Value < Maximum;
|
||||
private bool CanDecrement() => IsEnabled && Value > Minimum;
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
var x = e.X;
|
||||
if (x < ButtonWidth)
|
||||
if (e.X < ButtonWidth)
|
||||
{
|
||||
_isMinusPressed = true;
|
||||
if (CanDecrement()) Value -= _increment;
|
||||
if (CanDecrement()) Value -= Increment;
|
||||
}
|
||||
else if (x > Bounds.Width - ButtonWidth)
|
||||
else if (e.X > Bounds.Width - ButtonWidth)
|
||||
{
|
||||
_isPlusPressed = true;
|
||||
if (CanIncrement()) Value += _increment;
|
||||
if (CanIncrement()) Value += Increment;
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
|
|
@ -169,12 +243,12 @@ public class SkiaStepper : SkiaView
|
|||
{
|
||||
case Key.Up:
|
||||
case Key.Right:
|
||||
if (CanIncrement()) Value += _increment;
|
||||
if (CanIncrement()) Value += Increment;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Down:
|
||||
case Key.Left:
|
||||
if (CanDecrement()) Value -= _increment;
|
||||
if (CanDecrement()) Value -= Increment;
|
||||
e.Handled = true;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,37 +6,204 @@ using SkiaSharp;
|
|||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Skia-rendered toggle switch control.
|
||||
/// Skia-rendered toggle switch control with full XAML styling support.
|
||||
/// </summary>
|
||||
public class SkiaSwitch : SkiaView
|
||||
{
|
||||
private bool _isOn;
|
||||
private float _animationProgress; // 0 = off, 1 = on
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsOn.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsOnProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsOn),
|
||||
typeof(bool),
|
||||
typeof(SkiaSwitch),
|
||||
false,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).OnIsOnChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for OnTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OnTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(OnTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for OffTrackColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OffTrackColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(OffTrackColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0x9E, 0x9E, 0x9E),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for DisabledColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty DisabledColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(DisabledColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaSwitch),
|
||||
new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackWidth.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackWidthProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackWidth),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
52f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for TrackHeight.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TrackHeightProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(TrackHeight),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
32f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbRadius.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbRadiusProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbRadius),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
12f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ThumbPadding.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ThumbPaddingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(ThumbPadding),
|
||||
typeof(float),
|
||||
typeof(SkiaSwitch),
|
||||
4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaSwitch)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the switch is on.
|
||||
/// </summary>
|
||||
public bool IsOn
|
||||
{
|
||||
get => _isOn;
|
||||
set
|
||||
{
|
||||
if (_isOn != value)
|
||||
{
|
||||
_isOn = value;
|
||||
_animationProgress = value ? 1f : 0f;
|
||||
Toggled?.Invoke(this, new ToggledEventArgs(value));
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsOnProperty);
|
||||
set => SetValue(IsOnProperty, value);
|
||||
}
|
||||
|
||||
public SKColor OnTrackColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor OffTrackColor { get; set; } = new SKColor(0x9E, 0x9E, 0x9E);
|
||||
public SKColor ThumbColor { get; set; } = SKColors.White;
|
||||
public SKColor DisabledColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public float TrackWidth { get; set; } = 52;
|
||||
public float TrackHeight { get; set; } = 32;
|
||||
public float ThumbRadius { get; set; } = 12;
|
||||
public float ThumbPadding { get; set; } = 4;
|
||||
/// <summary>
|
||||
/// Gets or sets the on track color.
|
||||
/// </summary>
|
||||
public SKColor OnTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(OnTrackColorProperty);
|
||||
set => SetValue(OnTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the off track color.
|
||||
/// </summary>
|
||||
public SKColor OffTrackColor
|
||||
{
|
||||
get => (SKColor)GetValue(OffTrackColorProperty);
|
||||
set => SetValue(OffTrackColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb color.
|
||||
/// </summary>
|
||||
public SKColor ThumbColor
|
||||
{
|
||||
get => (SKColor)GetValue(ThumbColorProperty);
|
||||
set => SetValue(ThumbColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the disabled color.
|
||||
/// </summary>
|
||||
public SKColor DisabledColor
|
||||
{
|
||||
get => (SKColor)GetValue(DisabledColorProperty);
|
||||
set => SetValue(DisabledColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track width.
|
||||
/// </summary>
|
||||
public float TrackWidth
|
||||
{
|
||||
get => (float)GetValue(TrackWidthProperty);
|
||||
set => SetValue(TrackWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the track height.
|
||||
/// </summary>
|
||||
public float TrackHeight
|
||||
{
|
||||
get => (float)GetValue(TrackHeightProperty);
|
||||
set => SetValue(TrackHeightProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb radius.
|
||||
/// </summary>
|
||||
public float ThumbRadius
|
||||
{
|
||||
get => (float)GetValue(ThumbRadiusProperty);
|
||||
set => SetValue(ThumbRadiusProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the thumb padding.
|
||||
/// </summary>
|
||||
public float ThumbPadding
|
||||
{
|
||||
get => (float)GetValue(ThumbPaddingProperty);
|
||||
set => SetValue(ThumbPaddingProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private float _animationProgress; // 0 = off, 1 = on
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the switch is toggled.
|
||||
/// </summary>
|
||||
public event EventHandler<ToggledEventArgs>? Toggled;
|
||||
|
||||
public SkiaSwitch()
|
||||
|
|
@ -44,6 +211,14 @@ public class SkiaSwitch : SkiaView
|
|||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void OnIsOnChanged()
|
||||
{
|
||||
_animationProgress = IsOn ? 1f : 0f;
|
||||
Toggled?.Invoke(this, new ToggledEventArgs(IsOn));
|
||||
SkiaVisualStateManager.GoToState(this, IsOn ? SkiaVisualStateManager.CommonStates.On : SkiaVisualStateManager.CommonStates.Off);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var centerY = bounds.MidY;
|
||||
|
|
@ -142,12 +317,21 @@ public class SkiaSwitch : SkiaView
|
|||
}
|
||||
}
|
||||
|
||||
protected override void OnEnabledChanged()
|
||||
{
|
||||
base.OnEnabledChanged();
|
||||
SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled);
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(TrackWidth + 8, TrackHeight + 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for toggled events.
|
||||
/// </summary>
|
||||
public class ToggledEventArgs : EventArgs
|
||||
{
|
||||
public bool Value { get; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Controls;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Skia controls that support ControlTemplates.
|
||||
/// Provides infrastructure for completely redefining control appearance via XAML.
|
||||
/// </summary>
|
||||
public abstract class SkiaTemplatedView : SkiaView
|
||||
{
|
||||
private SkiaView? _templateRoot;
|
||||
private bool _templateApplied;
|
||||
|
||||
#region BindableProperties
|
||||
|
||||
public static readonly BindableProperty ControlTemplateProperty =
|
||||
BindableProperty.Create(nameof(ControlTemplate), typeof(ControlTemplate), typeof(SkiaTemplatedView), null,
|
||||
propertyChanged: OnControlTemplateChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the control template that defines the visual appearance.
|
||||
/// </summary>
|
||||
public ControlTemplate? ControlTemplate
|
||||
{
|
||||
get => (ControlTemplate?)GetValue(ControlTemplateProperty);
|
||||
set => SetValue(ControlTemplateProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root element created from the ControlTemplate.
|
||||
/// </summary>
|
||||
protected SkiaView? TemplateRoot => _templateRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a template has been applied.
|
||||
/// </summary>
|
||||
protected bool IsTemplateApplied => _templateApplied;
|
||||
|
||||
#endregion
|
||||
|
||||
private static void OnControlTemplateChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
if (bindable is SkiaTemplatedView view)
|
||||
{
|
||||
view.OnControlTemplateChanged((ControlTemplate?)oldValue, (ControlTemplate?)newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the ControlTemplate changes.
|
||||
/// </summary>
|
||||
protected virtual void OnControlTemplateChanged(ControlTemplate? oldTemplate, ControlTemplate? newTemplate)
|
||||
{
|
||||
_templateApplied = false;
|
||||
_templateRoot = null;
|
||||
|
||||
if (newTemplate != null)
|
||||
{
|
||||
ApplyTemplate();
|
||||
}
|
||||
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the current ControlTemplate if one is set.
|
||||
/// </summary>
|
||||
protected virtual void ApplyTemplate()
|
||||
{
|
||||
if (ControlTemplate == null || _templateApplied)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Create content from template
|
||||
var content = ControlTemplate.CreateContent();
|
||||
|
||||
// If the content is a MAUI Element, try to convert it to a SkiaView
|
||||
if (content is Element element)
|
||||
{
|
||||
_templateRoot = ConvertElementToSkiaView(element);
|
||||
}
|
||||
else if (content is SkiaView skiaView)
|
||||
{
|
||||
_templateRoot = skiaView;
|
||||
}
|
||||
|
||||
if (_templateRoot != null)
|
||||
{
|
||||
_templateRoot.Parent = this;
|
||||
OnTemplateApplied();
|
||||
}
|
||||
|
||||
_templateApplied = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error applying template: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after a template has been successfully applied.
|
||||
/// Override to perform template-specific initialization.
|
||||
/// </summary>
|
||||
protected virtual void OnTemplateApplied()
|
||||
{
|
||||
// Find and bind ContentPresenter if present
|
||||
var presenter = FindTemplateChild<SkiaContentPresenter>("PART_ContentPresenter");
|
||||
if (presenter != null)
|
||||
{
|
||||
OnContentPresenterFound(presenter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a ContentPresenter is found in the template.
|
||||
/// Override to set up the content binding.
|
||||
/// </summary>
|
||||
protected virtual void OnContentPresenterFound(SkiaContentPresenter presenter)
|
||||
{
|
||||
// Derived classes should override to bind their content
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a named element in the template tree.
|
||||
/// </summary>
|
||||
protected T? FindTemplateChild<T>(string name) where T : SkiaView
|
||||
{
|
||||
if (_templateRoot == null)
|
||||
return null;
|
||||
|
||||
return FindChild<T>(_templateRoot, name);
|
||||
}
|
||||
|
||||
private static T? FindChild<T>(SkiaView root, string name) where T : SkiaView
|
||||
{
|
||||
if (root is T typed && root.Name == name)
|
||||
return typed;
|
||||
|
||||
if (root is SkiaLayoutView layout)
|
||||
{
|
||||
foreach (var child in layout.Children)
|
||||
{
|
||||
var found = FindChild<T>(child, name);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
else if (root is SkiaContentPresenter presenter && presenter.Content != null)
|
||||
{
|
||||
return FindChild<T>(presenter.Content, name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Element to a SkiaView.
|
||||
/// Override to provide custom conversion logic.
|
||||
/// </summary>
|
||||
protected virtual SkiaView? ConvertElementToSkiaView(Element element)
|
||||
{
|
||||
// This is a simplified conversion - in a full implementation,
|
||||
// you would use the handler system to create proper platform views
|
||||
|
||||
return element switch
|
||||
{
|
||||
// Handle common layout types
|
||||
Microsoft.Maui.Controls.StackLayout sl => CreateSkiaStackLayout(sl),
|
||||
Microsoft.Maui.Controls.Grid grid => CreateSkiaGrid(grid),
|
||||
Microsoft.Maui.Controls.Border border => CreateSkiaBorder(border),
|
||||
Microsoft.Maui.Controls.Label label => CreateSkiaLabel(label),
|
||||
Microsoft.Maui.Controls.ContentPresenter cp => new SkiaContentPresenter(),
|
||||
_ => new SkiaLabel { Text = $"[{element.GetType().Name}]", TextColor = SKColors.Gray }
|
||||
};
|
||||
}
|
||||
|
||||
private SkiaStackLayout CreateSkiaStackLayout(Microsoft.Maui.Controls.StackLayout sl)
|
||||
{
|
||||
var layout = new SkiaStackLayout
|
||||
{
|
||||
Orientation = sl.Orientation == Microsoft.Maui.Controls.StackOrientation.Vertical
|
||||
? StackOrientation.Vertical
|
||||
: StackOrientation.Horizontal,
|
||||
Spacing = (float)sl.Spacing
|
||||
};
|
||||
|
||||
foreach (var child in sl.Children)
|
||||
{
|
||||
if (child is Element element)
|
||||
{
|
||||
var skiaChild = ConvertElementToSkiaView(element);
|
||||
if (skiaChild != null)
|
||||
layout.AddChild(skiaChild);
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
private SkiaGrid CreateSkiaGrid(Microsoft.Maui.Controls.Grid grid)
|
||||
{
|
||||
var layout = new SkiaGrid();
|
||||
|
||||
// Set row definitions
|
||||
foreach (var rowDef in grid.RowDefinitions)
|
||||
{
|
||||
var gridLength = rowDef.Height.IsAuto ? GridLength.Auto :
|
||||
rowDef.Height.IsStar ? new GridLength((float)rowDef.Height.Value, GridUnitType.Star) :
|
||||
new GridLength((float)rowDef.Height.Value, GridUnitType.Absolute);
|
||||
layout.RowDefinitions.Add(gridLength);
|
||||
}
|
||||
|
||||
// Set column definitions
|
||||
foreach (var colDef in grid.ColumnDefinitions)
|
||||
{
|
||||
var gridLength = colDef.Width.IsAuto ? GridLength.Auto :
|
||||
colDef.Width.IsStar ? new GridLength((float)colDef.Width.Value, GridUnitType.Star) :
|
||||
new GridLength((float)colDef.Width.Value, GridUnitType.Absolute);
|
||||
layout.ColumnDefinitions.Add(gridLength);
|
||||
}
|
||||
|
||||
// Add children
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is Element element)
|
||||
{
|
||||
var skiaChild = ConvertElementToSkiaView(element);
|
||||
if (skiaChild != null)
|
||||
{
|
||||
var row = Microsoft.Maui.Controls.Grid.GetRow((BindableObject)child);
|
||||
var col = Microsoft.Maui.Controls.Grid.GetColumn((BindableObject)child);
|
||||
var rowSpan = Microsoft.Maui.Controls.Grid.GetRowSpan((BindableObject)child);
|
||||
var colSpan = Microsoft.Maui.Controls.Grid.GetColumnSpan((BindableObject)child);
|
||||
|
||||
layout.AddChild(skiaChild, row, col, rowSpan, colSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
private SkiaBorder CreateSkiaBorder(Microsoft.Maui.Controls.Border border)
|
||||
{
|
||||
float cornerRadius = 0;
|
||||
if (border.StrokeShape is Microsoft.Maui.Controls.Shapes.RoundRectangle rr)
|
||||
{
|
||||
cornerRadius = (float)rr.CornerRadius.TopLeft;
|
||||
}
|
||||
|
||||
var skiaBorder = new SkiaBorder
|
||||
{
|
||||
CornerRadius = cornerRadius,
|
||||
StrokeThickness = (float)border.StrokeThickness
|
||||
};
|
||||
|
||||
if (border.Stroke is SolidColorBrush strokeBrush)
|
||||
{
|
||||
skiaBorder.Stroke = strokeBrush.Color.ToSKColor();
|
||||
}
|
||||
|
||||
if (border.Background is SolidColorBrush bgBrush)
|
||||
{
|
||||
skiaBorder.BackgroundColor = bgBrush.Color.ToSKColor();
|
||||
}
|
||||
|
||||
if (border.Content is Element content)
|
||||
{
|
||||
var skiaContent = ConvertElementToSkiaView(content);
|
||||
if (skiaContent != null)
|
||||
skiaBorder.AddChild(skiaContent);
|
||||
}
|
||||
|
||||
return skiaBorder;
|
||||
}
|
||||
|
||||
private SkiaLabel CreateSkiaLabel(Microsoft.Maui.Controls.Label label)
|
||||
{
|
||||
var skiaLabel = new SkiaLabel
|
||||
{
|
||||
Text = label.Text ?? "",
|
||||
FontSize = (float)label.FontSize
|
||||
};
|
||||
|
||||
if (label.TextColor != null)
|
||||
{
|
||||
skiaLabel.TextColor = label.TextColor.ToSKColor();
|
||||
}
|
||||
|
||||
return skiaLabel;
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
// Render the template
|
||||
_templateRoot.Draw(canvas);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Render default appearance
|
||||
DrawDefaultAppearance(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the default appearance when no template is applied.
|
||||
/// Override in derived classes to provide default rendering.
|
||||
/// </summary>
|
||||
protected abstract void DrawDefaultAppearance(SKCanvas canvas, SKRect bounds);
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
return _templateRoot.Measure(availableSize);
|
||||
}
|
||||
|
||||
return MeasureDefaultAppearance(availableSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the default appearance when no template is applied.
|
||||
/// Override in derived classes.
|
||||
/// </summary>
|
||||
protected virtual SKSize MeasureDefaultAppearance(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(100, 40);
|
||||
}
|
||||
|
||||
public new void Arrange(SKRect bounds)
|
||||
{
|
||||
base.Arrange(bounds);
|
||||
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
_templateRoot.Arrange(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
public override SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
if (!IsVisible || !Bounds.Contains(x, y))
|
||||
return null;
|
||||
|
||||
if (_templateRoot != null && _templateApplied)
|
||||
{
|
||||
var hit = _templateRoot.HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using SkiaSharp;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
|
|
@ -10,77 +11,202 @@ namespace Microsoft.Maui.Platform;
|
|||
/// </summary>
|
||||
public class SkiaTimePicker : SkiaView
|
||||
{
|
||||
private TimeSpan _time = DateTime.Now.TimeOfDay;
|
||||
private bool _isOpen;
|
||||
private string _format = "t";
|
||||
private int _selectedHour;
|
||||
private int _selectedMinute;
|
||||
private bool _isSelectingHours = true;
|
||||
#region BindableProperties
|
||||
|
||||
// Styling
|
||||
public SKColor TextColor { get; set; } = SKColors.Black;
|
||||
public SKColor BorderColor { get; set; } = new SKColor(0xBD, 0xBD, 0xBD);
|
||||
public SKColor ClockBackgroundColor { get; set; } = SKColors.White;
|
||||
public SKColor ClockFaceColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
|
||||
public SKColor SelectedColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public SKColor HeaderColor { get; set; } = new SKColor(0x21, 0x96, 0xF3);
|
||||
public float FontSize { get; set; } = 14;
|
||||
public float CornerRadius { get; set; } = 4;
|
||||
public static readonly BindableProperty TimeProperty =
|
||||
BindableProperty.Create(nameof(Time), typeof(TimeSpan), typeof(SkiaTimePicker), DateTime.Now.TimeOfDay, BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).OnTimePropertyChanged());
|
||||
|
||||
private const float ClockSize = 280;
|
||||
private const float ClockRadius = 100;
|
||||
private const float HeaderHeight = 80;
|
||||
public static readonly BindableProperty FormatProperty =
|
||||
BindableProperty.Create(nameof(Format), typeof(string), typeof(SkiaTimePicker), "t",
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty TextColorProperty =
|
||||
BindableProperty.Create(nameof(TextColor), typeof(SKColor), typeof(SkiaTimePicker), SKColors.Black,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty BorderColorProperty =
|
||||
BindableProperty.Create(nameof(BorderColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0xBD, 0xBD, 0xBD),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ClockBackgroundColorProperty =
|
||||
BindableProperty.Create(nameof(ClockBackgroundColor), typeof(SKColor), typeof(SkiaTimePicker), SKColors.White,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty ClockFaceColorProperty =
|
||||
BindableProperty.Create(nameof(ClockFaceColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0xF5, 0xF5, 0xF5),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty SelectedColorProperty =
|
||||
BindableProperty.Create(nameof(SelectedColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty HeaderColorProperty =
|
||||
BindableProperty.Create(nameof(HeaderColor), typeof(SKColor), typeof(SkiaTimePicker), new SKColor(0x21, 0x96, 0xF3),
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
public static readonly BindableProperty FontSizeProperty =
|
||||
BindableProperty.Create(nameof(FontSize), typeof(float), typeof(SkiaTimePicker), 14f,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).InvalidateMeasure());
|
||||
|
||||
public static readonly BindableProperty CornerRadiusProperty =
|
||||
BindableProperty.Create(nameof(CornerRadius), typeof(float), typeof(SkiaTimePicker), 4f,
|
||||
propertyChanged: (b, o, n) => ((SkiaTimePicker)b).Invalidate());
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
public TimeSpan Time
|
||||
{
|
||||
get => _time;
|
||||
set
|
||||
{
|
||||
if (_time != value)
|
||||
{
|
||||
_time = value;
|
||||
_selectedHour = _time.Hours;
|
||||
_selectedMinute = _time.Minutes;
|
||||
TimeSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (TimeSpan)GetValue(TimeProperty);
|
||||
set => SetValue(TimeProperty, value);
|
||||
}
|
||||
|
||||
public string Format
|
||||
{
|
||||
get => _format;
|
||||
set { _format = value; Invalidate(); }
|
||||
get => (string)GetValue(FormatProperty);
|
||||
set => SetValue(FormatProperty, value);
|
||||
}
|
||||
|
||||
public SKColor TextColor
|
||||
{
|
||||
get => (SKColor)GetValue(TextColorProperty);
|
||||
set => SetValue(TextColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor BorderColor
|
||||
{
|
||||
get => (SKColor)GetValue(BorderColorProperty);
|
||||
set => SetValue(BorderColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ClockBackgroundColor
|
||||
{
|
||||
get => (SKColor)GetValue(ClockBackgroundColorProperty);
|
||||
set => SetValue(ClockBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor ClockFaceColor
|
||||
{
|
||||
get => (SKColor)GetValue(ClockFaceColorProperty);
|
||||
set => SetValue(ClockFaceColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor SelectedColor
|
||||
{
|
||||
get => (SKColor)GetValue(SelectedColorProperty);
|
||||
set => SetValue(SelectedColorProperty, value);
|
||||
}
|
||||
|
||||
public SKColor HeaderColor
|
||||
{
|
||||
get => (SKColor)GetValue(HeaderColorProperty);
|
||||
set => SetValue(HeaderColorProperty, value);
|
||||
}
|
||||
|
||||
public float FontSize
|
||||
{
|
||||
get => (float)GetValue(FontSizeProperty);
|
||||
set => SetValue(FontSizeProperty, value);
|
||||
}
|
||||
|
||||
public float CornerRadius
|
||||
{
|
||||
get => (float)GetValue(CornerRadiusProperty);
|
||||
set => SetValue(CornerRadiusProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => _isOpen;
|
||||
set { _isOpen = value; Invalidate(); }
|
||||
set
|
||||
{
|
||||
if (_isOpen != value)
|
||||
{
|
||||
_isOpen = value;
|
||||
if (_isOpen)
|
||||
RegisterPopupOverlay(this, DrawClockOverlay);
|
||||
else
|
||||
UnregisterPopupOverlay(this);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isOpen;
|
||||
private int _selectedHour;
|
||||
private int _selectedMinute;
|
||||
private bool _isSelectingHours = true;
|
||||
|
||||
private const float ClockSize = 280;
|
||||
private const float ClockRadius = 100;
|
||||
private const float HeaderHeight = 80;
|
||||
private const float PopupHeight = ClockSize + HeaderHeight;
|
||||
|
||||
public event EventHandler? TimeSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the clock popup rectangle with edge detection applied.
|
||||
/// </summary>
|
||||
private SKRect GetPopupRect(SKRect pickerBounds)
|
||||
{
|
||||
// Get window dimensions for edge detection
|
||||
var windowWidth = LinuxApplication.Current?.MainWindow?.Width ?? 800;
|
||||
var windowHeight = LinuxApplication.Current?.MainWindow?.Height ?? 600;
|
||||
|
||||
// Calculate default position (below the picker)
|
||||
var popupLeft = pickerBounds.Left;
|
||||
var popupTop = pickerBounds.Bottom + 4;
|
||||
|
||||
// Edge detection: adjust horizontal position if popup would go off-screen
|
||||
if (popupLeft + ClockSize > windowWidth)
|
||||
{
|
||||
popupLeft = windowWidth - ClockSize - 4;
|
||||
}
|
||||
if (popupLeft < 0) popupLeft = 4;
|
||||
|
||||
// Edge detection: show above if popup would go off-screen vertically
|
||||
if (popupTop + PopupHeight > windowHeight)
|
||||
{
|
||||
popupTop = pickerBounds.Top - PopupHeight - 4;
|
||||
}
|
||||
if (popupTop < 0) popupTop = 4;
|
||||
|
||||
return new SKRect(popupLeft, popupTop, popupLeft + ClockSize, popupTop + PopupHeight);
|
||||
}
|
||||
|
||||
public SkiaTimePicker()
|
||||
{
|
||||
IsFocusable = true;
|
||||
_selectedHour = _time.Hours;
|
||||
_selectedMinute = _time.Minutes;
|
||||
_selectedHour = DateTime.Now.Hour;
|
||||
_selectedMinute = DateTime.Now.Minute;
|
||||
}
|
||||
|
||||
private void OnTimePropertyChanged()
|
||||
{
|
||||
_selectedHour = Time.Hours;
|
||||
_selectedMinute = Time.Minutes;
|
||||
TimeSelected?.Invoke(this, EventArgs.Empty);
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
private void DrawClockOverlay(SKCanvas canvas)
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
// Use ScreenBounds for popup drawing (accounts for scroll offset)
|
||||
DrawClockPopup(canvas, ScreenBounds);
|
||||
}
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
DrawPickerButton(canvas, bounds);
|
||||
|
||||
if (_isOpen)
|
||||
{
|
||||
DrawClockPopup(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPickerButton(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5),
|
||||
|
|
@ -89,7 +215,6 @@ public class SkiaTimePicker : SkiaView
|
|||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = IsFocused ? SelectedColor : BorderColor,
|
||||
|
|
@ -99,23 +224,17 @@ public class SkiaTimePicker : SkiaView
|
|||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), borderPaint);
|
||||
|
||||
// Draw time text
|
||||
using var font = new SKFont(SKTypeface.Default, FontSize);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = IsEnabled ? TextColor : TextColor.WithAlpha(128),
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var timeText = DateTime.Today.Add(_time).ToString(_format);
|
||||
var timeText = DateTime.Today.Add(Time).ToString(Format);
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(timeText, ref textBounds);
|
||||
canvas.DrawText(timeText, bounds.Left + 12, bounds.MidY - textBounds.MidY, textPaint);
|
||||
|
||||
var textX = bounds.Left + 12;
|
||||
var textY = bounds.MidY - textBounds.MidY;
|
||||
canvas.DrawText(timeText, textX, textY, textPaint);
|
||||
|
||||
// Draw clock icon
|
||||
DrawClockIcon(canvas, new SKRect(bounds.Right - 36, bounds.MidY - 10, bounds.Right - 12, bounds.MidY + 10));
|
||||
}
|
||||
|
||||
|
|
@ -128,108 +247,52 @@ public class SkiaTimePicker : SkiaView
|
|||
StrokeWidth = 1.5f,
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 2;
|
||||
|
||||
// Clock circle
|
||||
canvas.DrawCircle(centerX, centerY, radius, paint);
|
||||
|
||||
// Hour hand
|
||||
canvas.DrawLine(centerX, centerY, centerX, centerY - radius * 0.5f, paint);
|
||||
|
||||
// Minute hand
|
||||
canvas.DrawLine(centerX, centerY, centerX + radius * 0.4f, centerY, paint);
|
||||
|
||||
// Center dot
|
||||
canvas.DrawCircle(bounds.MidX, bounds.MidY, radius, paint);
|
||||
canvas.DrawLine(bounds.MidX, bounds.MidY, bounds.MidX, bounds.MidY - radius * 0.5f, paint);
|
||||
canvas.DrawLine(bounds.MidX, bounds.MidY, bounds.MidX + radius * 0.4f, bounds.MidY, paint);
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawCircle(centerX, centerY, 1.5f, paint);
|
||||
canvas.DrawCircle(bounds.MidX, bounds.MidY, 1.5f, paint);
|
||||
}
|
||||
|
||||
private void DrawClockPopup(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
var popupRect = new SKRect(
|
||||
bounds.Left,
|
||||
bounds.Bottom + 4,
|
||||
bounds.Left + ClockSize,
|
||||
bounds.Bottom + 4 + HeaderHeight + ClockSize);
|
||||
var popupRect = GetPopupRect(bounds);
|
||||
|
||||
// Draw shadow
|
||||
using var shadowPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(0, 0, 0, 40),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4),
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
using var shadowPaint = new SKPaint { Color = new SKColor(0, 0, 0, 40), MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(new SKRect(popupRect.Left + 2, popupRect.Top + 2, popupRect.Right + 2, popupRect.Bottom + 2), CornerRadius), shadowPaint);
|
||||
|
||||
// Draw background
|
||||
using var bgPaint = new SKPaint
|
||||
{
|
||||
Color = ClockBackgroundColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var bgPaint = new SKPaint { Color = ClockBackgroundColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, CornerRadius), bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = BorderColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var borderPaint = new SKPaint { Color = BorderColor, Style = SKPaintStyle.Stroke, StrokeWidth = 1, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, CornerRadius), borderPaint);
|
||||
|
||||
// Draw header with time display
|
||||
DrawTimeHeader(canvas, new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + HeaderHeight));
|
||||
|
||||
// Draw clock face
|
||||
DrawClockFace(canvas, new SKRect(popupRect.Left, popupRect.Top + HeaderHeight, popupRect.Right, popupRect.Bottom));
|
||||
}
|
||||
|
||||
private void DrawTimeHeader(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
// Draw header background
|
||||
using var headerPaint = new SKPaint
|
||||
{
|
||||
Color = HeaderColor,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
using var headerPaint = new SKPaint { Color = HeaderColor, Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + CornerRadius * 2), CornerRadius));
|
||||
canvas.DrawRect(bounds, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(bounds.Left, bounds.Top + CornerRadius, bounds.Right, bounds.Bottom), headerPaint);
|
||||
|
||||
// Draw time display
|
||||
using var font = new SKFont(SKTypeface.Default, 32);
|
||||
using var selectedPaint = new SKPaint(font)
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var unselectedPaint = new SKPaint(font)
|
||||
{
|
||||
Color = new SKColor(255, 255, 255, 150),
|
||||
IsAntialias = true
|
||||
};
|
||||
using var selectedPaint = new SKPaint(font) { Color = SKColors.White, IsAntialias = true };
|
||||
using var unselectedPaint = new SKPaint(font) { Color = new SKColor(255, 255, 255, 150), IsAntialias = true };
|
||||
|
||||
var hourText = _selectedHour.ToString("D2");
|
||||
var minuteText = _selectedMinute.ToString("D2");
|
||||
var colonText = ":";
|
||||
|
||||
var hourPaint = _isSelectingHours ? selectedPaint : unselectedPaint;
|
||||
var minutePaint = _isSelectingHours ? unselectedPaint : selectedPaint;
|
||||
|
||||
var hourBounds = new SKRect();
|
||||
var colonBounds = new SKRect();
|
||||
var minuteBounds = new SKRect();
|
||||
var hourBounds = new SKRect(); var colonBounds = new SKRect(); var minuteBounds = new SKRect();
|
||||
hourPaint.MeasureText(hourText, ref hourBounds);
|
||||
selectedPaint.MeasureText(colonText, ref colonBounds);
|
||||
selectedPaint.MeasureText(":", ref colonBounds);
|
||||
minutePaint.MeasureText(minuteText, ref minuteBounds);
|
||||
|
||||
var totalWidth = hourBounds.Width + colonBounds.Width + minuteBounds.Width + 8;
|
||||
|
|
@ -237,7 +300,7 @@ public class SkiaTimePicker : SkiaView
|
|||
var centerY = bounds.MidY - hourBounds.MidY;
|
||||
|
||||
canvas.DrawText(hourText, startX, centerY, hourPaint);
|
||||
canvas.DrawText(colonText, startX + hourBounds.Width + 4, centerY, selectedPaint);
|
||||
canvas.DrawText(":", startX + hourBounds.Width + 4, centerY, selectedPaint);
|
||||
canvas.DrawText(minuteText, startX + hourBounds.Width + colonBounds.Width + 8, centerY, minutePaint);
|
||||
}
|
||||
|
||||
|
|
@ -246,94 +309,53 @@ public class SkiaTimePicker : SkiaView
|
|||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
|
||||
// Draw clock face background
|
||||
using var facePaint = new SKPaint
|
||||
{
|
||||
Color = ClockFaceColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var facePaint = new SKPaint { Color = ClockFaceColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(centerX, centerY, ClockRadius + 20, facePaint);
|
||||
|
||||
// Draw numbers
|
||||
using var font = new SKFont(SKTypeface.Default, 14);
|
||||
using var textPaint = new SKPaint(font)
|
||||
{
|
||||
Color = TextColor,
|
||||
IsAntialias = true
|
||||
};
|
||||
using var textPaint = new SKPaint(font) { Color = TextColor, IsAntialias = true };
|
||||
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
// Draw hour numbers (1-12)
|
||||
for (int i = 1; i <= 12; i++)
|
||||
{
|
||||
var angle = (i * 30 - 90) * Math.PI / 180;
|
||||
var x = centerX + (float)(ClockRadius * Math.Cos(angle));
|
||||
var y = centerY + (float)(ClockRadius * Math.Sin(angle));
|
||||
|
||||
var numText = i.ToString();
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(numText, ref textBounds);
|
||||
|
||||
var isSelected = (_selectedHour % 12 == i % 12);
|
||||
if (isSelected)
|
||||
{
|
||||
using var selectedBgPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(x, y, 18, selectedBgPaint);
|
||||
using var selBgPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(x, y, 18, selBgPaint);
|
||||
textPaint.Color = SKColors.White;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPaint.Color = TextColor;
|
||||
}
|
||||
|
||||
canvas.DrawText(numText, x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
else textPaint.Color = TextColor;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(i.ToString(), ref textBounds);
|
||||
canvas.DrawText(i.ToString(), x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
// Draw center point and hand
|
||||
DrawClockHand(canvas, centerX, centerY, (_selectedHour % 12) * 30 - 90, ClockRadius - 18);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Draw minute numbers (0, 5, 10, ... 55)
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
var minute = i * 5;
|
||||
var angle = (minute * 6 - 90) * Math.PI / 180;
|
||||
var x = centerX + (float)(ClockRadius * Math.Cos(angle));
|
||||
var y = centerY + (float)(ClockRadius * Math.Sin(angle));
|
||||
|
||||
var numText = minute.ToString("D2");
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(numText, ref textBounds);
|
||||
|
||||
var isSelected = (_selectedMinute / 5 == i);
|
||||
if (isSelected)
|
||||
{
|
||||
using var selectedBgPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(x, y, 18, selectedBgPaint);
|
||||
using var selBgPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(x, y, 18, selBgPaint);
|
||||
textPaint.Color = SKColors.White;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPaint.Color = TextColor;
|
||||
}
|
||||
|
||||
canvas.DrawText(numText, x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
else textPaint.Color = TextColor;
|
||||
var textBounds = new SKRect();
|
||||
textPaint.MeasureText(minute.ToString("D2"), ref textBounds);
|
||||
canvas.DrawText(minute.ToString("D2"), x - textBounds.MidX, y - textBounds.MidY, textPaint);
|
||||
}
|
||||
|
||||
// Draw center point and hand
|
||||
DrawClockHand(canvas, centerX, centerY, _selectedMinute * 6 - 90, ClockRadius - 18);
|
||||
}
|
||||
}
|
||||
|
|
@ -341,19 +363,8 @@ public class SkiaTimePicker : SkiaView
|
|||
private void DrawClockHand(SKCanvas canvas, float centerX, float centerY, float angleDegrees, float length)
|
||||
{
|
||||
var angle = angleDegrees * Math.PI / 180;
|
||||
var endX = centerX + (float)(length * Math.Cos(angle));
|
||||
var endY = centerY + (float)(length * Math.Sin(angle));
|
||||
|
||||
using var handPaint = new SKPaint
|
||||
{
|
||||
Color = SelectedColor,
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawLine(centerX, centerY, endX, endY, handPaint);
|
||||
|
||||
// Center dot
|
||||
using var handPaint = new SKPaint { Color = SelectedColor, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true };
|
||||
canvas.DrawLine(centerX, centerY, centerX + (float)(length * Math.Cos(angle)), centerY + (float)(length * Math.Sin(angle)), handPaint);
|
||||
handPaint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawCircle(centerX, centerY, 6, handPaint);
|
||||
}
|
||||
|
|
@ -362,31 +373,24 @@ public class SkiaTimePicker : SkiaView
|
|||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
if (_isOpen)
|
||||
if (IsOpen)
|
||||
{
|
||||
var popupTop = Bounds.Bottom + 4;
|
||||
var popupLeft = Bounds.Left;
|
||||
// Use ScreenBounds for popup coordinate calculations (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
var popupRect = GetPopupRect(screenBounds);
|
||||
|
||||
// Check header click (toggle hours/minutes)
|
||||
if (e.Y >= popupTop && e.Y < popupTop + HeaderHeight)
|
||||
// Check if click is in header area
|
||||
var headerRect = new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + HeaderHeight);
|
||||
if (headerRect.Contains(e.X, e.Y))
|
||||
{
|
||||
var centerX = popupLeft + ClockSize / 2;
|
||||
if (e.X < centerX)
|
||||
{
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
_isSelectingHours = e.X < popupRect.Left + ClockSize / 2;
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check clock face click
|
||||
var clockCenterX = popupLeft + ClockSize / 2;
|
||||
var clockCenterY = popupTop + HeaderHeight + ClockSize / 2;
|
||||
|
||||
// Check if click is in clock face area
|
||||
var clockCenterX = popupRect.Left + ClockSize / 2;
|
||||
var clockCenterY = popupRect.Top + HeaderHeight + ClockSize / 2;
|
||||
var dx = e.X - clockCenterX;
|
||||
var dy = e.Y - clockCenterY;
|
||||
var distance = Math.Sqrt(dx * dx + dy * dy);
|
||||
|
|
@ -400,114 +404,86 @@ public class SkiaTimePicker : SkiaView
|
|||
{
|
||||
_selectedHour = ((int)Math.Round(angle / 30) % 12);
|
||||
if (_selectedHour == 0) _selectedHour = 12;
|
||||
// Preserve AM/PM
|
||||
if (_time.Hours >= 12 && _selectedHour != 12)
|
||||
_selectedHour += 12;
|
||||
else if (_time.Hours < 12 && _selectedHour == 12)
|
||||
_selectedHour = 0;
|
||||
|
||||
_isSelectingHours = false; // Move to minutes
|
||||
if (Time.Hours >= 12 && _selectedHour != 12) _selectedHour += 12;
|
||||
else if (Time.Hours < 12 && _selectedHour == 12) _selectedHour = 0;
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = ((int)Math.Round(angle / 6) % 60);
|
||||
// Apply the time
|
||||
Time = new TimeSpan(_selectedHour, _selectedMinute, 0);
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Click outside popup - close
|
||||
if (e.Y < popupTop)
|
||||
// Click is outside clock - check if it's on the picker itself to toggle
|
||||
if (screenBounds.Contains(e.X, e.Y))
|
||||
{
|
||||
_isOpen = false;
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
IsOpen = true;
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public override void OnFocusLost()
|
||||
{
|
||||
base.OnFocusLost();
|
||||
// Close popup when focus is lost (clicking outside)
|
||||
if (IsOpen)
|
||||
{
|
||||
IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Enter:
|
||||
case Key.Space:
|
||||
if (_isOpen)
|
||||
{
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_isSelectingHours = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Time = new TimeSpan(_selectedHour, _selectedMinute, 0);
|
||||
_isOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_isOpen = true;
|
||||
_isSelectingHours = true;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Escape:
|
||||
if (_isOpen)
|
||||
{
|
||||
_isOpen = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_selectedHour = (_selectedHour + 1) % 24;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = (_selectedMinute + 1) % 60;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
if (_isSelectingHours)
|
||||
{
|
||||
_selectedHour = (_selectedHour - 1 + 24) % 24;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedMinute = (_selectedMinute - 1 + 60) % 60;
|
||||
}
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Left:
|
||||
case Key.Right:
|
||||
_isSelectingHours = !_isSelectingHours;
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter: case Key.Space:
|
||||
if (IsOpen) { if (_isSelectingHours) _isSelectingHours = false; else { Time = new TimeSpan(_selectedHour, _selectedMinute, 0); IsOpen = false; } }
|
||||
else { IsOpen = true; _isSelectingHours = true; }
|
||||
e.Handled = true; break;
|
||||
case Key.Escape: if (IsOpen) { IsOpen = false; e.Handled = true; } break;
|
||||
case Key.Up: if (_isSelectingHours) _selectedHour = (_selectedHour + 1) % 24; else _selectedMinute = (_selectedMinute + 1) % 60; e.Handled = true; break;
|
||||
case Key.Down: if (_isSelectingHours) _selectedHour = (_selectedHour - 1 + 24) % 24; else _selectedMinute = (_selectedMinute - 1 + 60) % 60; e.Handled = true; break;
|
||||
case Key.Left: case Key.Right: _isSelectingHours = !_isSelectingHours; e.Handled = true; break;
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
|
||||
40);
|
||||
return new SKSize(availableSize.Width < float.MaxValue ? Math.Min(availableSize.Width, 200) : 200, 40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to include clock popup area in hit testing.
|
||||
/// </summary>
|
||||
protected override bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Use ScreenBounds for hit testing (accounts for scroll offset)
|
||||
var screenBounds = ScreenBounds;
|
||||
|
||||
// Always include the picker button itself
|
||||
if (screenBounds.Contains(x, y))
|
||||
return true;
|
||||
|
||||
// When open, also include the clock popup area (with edge detection)
|
||||
if (_isOpen)
|
||||
{
|
||||
var popupRect = GetPopupRect(screenBounds);
|
||||
return popupRect.Contains(x, y);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ namespace Microsoft.Maui.Platform;
|
|||
|
||||
/// <summary>
|
||||
/// Base class for all Skia-rendered views on Linux.
|
||||
/// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager.
|
||||
/// </summary>
|
||||
public abstract class SkiaView : IDisposable
|
||||
public abstract class SkiaView : BindableObject, IDisposable
|
||||
{
|
||||
// Popup overlay system for dropdowns, calendars, etc.
|
||||
private static readonly List<(SkiaView Owner, Action<SKCanvas> Draw)> _popupOverlays = new();
|
||||
|
|
@ -32,7 +33,7 @@ public abstract class SkiaView : IDisposable
|
|||
{
|
||||
canvas.Restore();
|
||||
}
|
||||
|
||||
|
||||
foreach (var (_, draw) in _popupOverlays)
|
||||
{
|
||||
canvas.Save();
|
||||
|
|
@ -41,6 +42,189 @@ public abstract class SkiaView : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the popup owner that should receive pointer events at the given coordinates.
|
||||
/// This allows popups to receive events even outside their normal bounds.
|
||||
/// </summary>
|
||||
public static SkiaView? GetPopupOwnerAt(float x, float y)
|
||||
{
|
||||
// Check in reverse order (topmost popup first)
|
||||
for (int i = _popupOverlays.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var owner = _popupOverlays[i].Owner;
|
||||
if (owner.HitTestPopupArea(x, y))
|
||||
{
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there are any active popup overlays.
|
||||
/// </summary>
|
||||
public static bool HasActivePopup => _popupOverlays.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Override this to define the popup area for hit testing.
|
||||
/// </summary>
|
||||
protected virtual bool HitTestPopupArea(float x, float y)
|
||||
{
|
||||
// Default: no popup area beyond normal bounds
|
||||
return Bounds.Contains(x, y);
|
||||
}
|
||||
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsVisible.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsVisibleProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsVisible),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).OnVisibilityChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsEnabled.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsEnabledProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsEnabled),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).OnEnabledChanged());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Opacity.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OpacityProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Opacity),
|
||||
typeof(float),
|
||||
typeof(SkiaView),
|
||||
1.0f,
|
||||
coerceValue: (b, v) => Math.Clamp((float)v, 0f, 1f),
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for BackgroundColor.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty BackgroundColorProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(BackgroundColor),
|
||||
typeof(SKColor),
|
||||
typeof(SkiaView),
|
||||
SKColors.Transparent,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for WidthRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty WidthRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(WidthRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
-1.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HeightRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HeightRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HeightRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
-1.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MinimumWidthRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumWidthRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MinimumWidthRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for MinimumHeightRequest.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MinimumHeightRequestProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(MinimumHeightRequest),
|
||||
typeof(double),
|
||||
typeof(SkiaView),
|
||||
0.0,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for IsFocusable.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsFocusableProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(IsFocusable),
|
||||
typeof(bool),
|
||||
typeof(SkiaView),
|
||||
false);
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Margin.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty MarginProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Margin),
|
||||
typeof(Thickness),
|
||||
typeof(SkiaView),
|
||||
default(Thickness),
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for HorizontalOptions.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HorizontalOptionsProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(HorizontalOptions),
|
||||
typeof(LayoutOptions),
|
||||
typeof(SkiaView),
|
||||
LayoutOptions.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for VerticalOptions.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VerticalOptionsProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(VerticalOptions),
|
||||
typeof(LayoutOptions),
|
||||
typeof(SkiaView),
|
||||
LayoutOptions.Fill,
|
||||
propertyChanged: (b, o, n) => ((SkiaView)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for Name (used for template child lookup).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty NameProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Name),
|
||||
typeof(string),
|
||||
typeof(SkiaView),
|
||||
string.Empty);
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _disposed;
|
||||
private SKRect _bounds;
|
||||
private SkiaView? _parent;
|
||||
private readonly List<SkiaView> _children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the absolute bounds of this view in screen coordinates.
|
||||
/// </summary>
|
||||
|
|
@ -64,15 +248,6 @@ public abstract class SkiaView : IDisposable
|
|||
return bounds;
|
||||
}
|
||||
|
||||
private bool _disposed;
|
||||
private SKRect _bounds;
|
||||
private bool _isVisible = true;
|
||||
private bool _isEnabled = true;
|
||||
private float _opacity = 1.0f;
|
||||
private SKColor _backgroundColor = SKColors.Transparent;
|
||||
private SkiaView? _parent;
|
||||
private readonly List<SkiaView> _children = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bounds of this view in parent coordinates.
|
||||
/// </summary>
|
||||
|
|
@ -94,15 +269,8 @@ public abstract class SkiaView : IDisposable
|
|||
/// </summary>
|
||||
public bool IsVisible
|
||||
{
|
||||
get => _isVisible;
|
||||
set
|
||||
{
|
||||
if (_isVisible != value)
|
||||
{
|
||||
_isVisible = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsVisibleProperty);
|
||||
set => SetValue(IsVisibleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -110,15 +278,8 @@ public abstract class SkiaView : IDisposable
|
|||
/// </summary>
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set
|
||||
{
|
||||
if (_isEnabled != value)
|
||||
{
|
||||
_isEnabled = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (bool)GetValue(IsEnabledProperty);
|
||||
set => SetValue(IsEnabledProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -126,21 +287,14 @@ public abstract class SkiaView : IDisposable
|
|||
/// </summary>
|
||||
public float Opacity
|
||||
{
|
||||
get => _opacity;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0f, 1f);
|
||||
if (_opacity != clamped)
|
||||
{
|
||||
_opacity = clamped;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
get => (float)GetValue(OpacityProperty);
|
||||
set => SetValue(OpacityProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the background color.
|
||||
/// </summary>
|
||||
private SKColor _backgroundColor = SKColors.Transparent;
|
||||
public SKColor BackgroundColor
|
||||
{
|
||||
get => _backgroundColor;
|
||||
|
|
@ -149,6 +303,7 @@ public abstract class SkiaView : IDisposable
|
|||
if (_backgroundColor != value)
|
||||
{
|
||||
_backgroundColor = value;
|
||||
SetValue(BackgroundColorProperty, value); // Keep BindableProperty in sync for bindings
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
|
@ -157,17 +312,101 @@ public abstract class SkiaView : IDisposable
|
|||
/// <summary>
|
||||
/// Gets or sets the requested width.
|
||||
/// </summary>
|
||||
public double RequestedWidth { get; set; } = -1;
|
||||
public double WidthRequest
|
||||
{
|
||||
get => (double)GetValue(WidthRequestProperty);
|
||||
set => SetValue(WidthRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested height.
|
||||
/// </summary>
|
||||
public double RequestedHeight { get; set; } = -1;
|
||||
public double HeightRequest
|
||||
{
|
||||
get => (double)GetValue(HeightRequestProperty);
|
||||
set => SetValue(HeightRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum width request.
|
||||
/// </summary>
|
||||
public double MinimumWidthRequest
|
||||
{
|
||||
get => (double)GetValue(MinimumWidthRequestProperty);
|
||||
set => SetValue(MinimumWidthRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum height request.
|
||||
/// </summary>
|
||||
public double MinimumHeightRequest
|
||||
{
|
||||
get => (double)GetValue(MinimumHeightRequestProperty);
|
||||
set => SetValue(MinimumHeightRequestProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested width (backwards compatibility alias).
|
||||
/// </summary>
|
||||
public double RequestedWidth
|
||||
{
|
||||
get => WidthRequest;
|
||||
set => WidthRequest = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the requested height (backwards compatibility alias).
|
||||
/// </summary>
|
||||
public double RequestedHeight
|
||||
{
|
||||
get => HeightRequest;
|
||||
set => HeightRequest = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this view can receive keyboard focus.
|
||||
/// </summary>
|
||||
public bool IsFocusable { get; set; }
|
||||
public bool IsFocusable
|
||||
{
|
||||
get => (bool)GetValue(IsFocusableProperty);
|
||||
set => SetValue(IsFocusableProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the margin around this view.
|
||||
/// </summary>
|
||||
public Thickness Margin
|
||||
{
|
||||
get => (Thickness)GetValue(MarginProperty);
|
||||
set => SetValue(MarginProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal layout options.
|
||||
/// </summary>
|
||||
public LayoutOptions HorizontalOptions
|
||||
{
|
||||
get => (LayoutOptions)GetValue(HorizontalOptionsProperty);
|
||||
set => SetValue(HorizontalOptionsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical layout options.
|
||||
/// </summary>
|
||||
public LayoutOptions VerticalOptions
|
||||
{
|
||||
get => (LayoutOptions)GetValue(VerticalOptionsProperty);
|
||||
set => SetValue(VerticalOptionsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this view (used for template child lookup).
|
||||
/// </summary>
|
||||
public string Name
|
||||
{
|
||||
get => (string)GetValue(NameProperty);
|
||||
set => SetValue(NameProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this view currently has keyboard focus.
|
||||
|
|
@ -183,6 +422,34 @@ public abstract class SkiaView : IDisposable
|
|||
internal set => _parent = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bounds of this view in screen coordinates (accounting for scroll offsets).
|
||||
/// </summary>
|
||||
public SKRect ScreenBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
var bounds = Bounds;
|
||||
var parent = _parent;
|
||||
|
||||
// Walk up the tree and adjust for scroll offsets
|
||||
while (parent != null)
|
||||
{
|
||||
if (parent is SkiaScrollView scrollView)
|
||||
{
|
||||
bounds = new SKRect(
|
||||
bounds.Left - scrollView.ScrollX,
|
||||
bounds.Top - scrollView.ScrollY,
|
||||
bounds.Right - scrollView.ScrollX,
|
||||
bounds.Bottom - scrollView.ScrollY);
|
||||
}
|
||||
parent = parent.Parent;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the desired size calculated during measure.
|
||||
/// </summary>
|
||||
|
|
@ -198,6 +465,36 @@ public abstract class SkiaView : IDisposable
|
|||
/// </summary>
|
||||
public event EventHandler? Invalidated;
|
||||
|
||||
/// <summary>
|
||||
/// Called when visibility changes.
|
||||
/// </summary>
|
||||
protected virtual void OnVisibilityChanged()
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when enabled state changes.
|
||||
/// </summary>
|
||||
protected virtual void OnEnabledChanged()
|
||||
{
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when binding context changes. Propagates to children.
|
||||
/// </summary>
|
||||
protected override void OnBindingContextChanged()
|
||||
{
|
||||
base.OnBindingContextChanged();
|
||||
|
||||
// Propagate binding context to children
|
||||
foreach (var child in _children)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child view.
|
||||
/// </summary>
|
||||
|
|
@ -208,6 +505,13 @@ public abstract class SkiaView : IDisposable
|
|||
|
||||
child._parent = this;
|
||||
_children.Add(child);
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +538,13 @@ public abstract class SkiaView : IDisposable
|
|||
|
||||
child._parent = this;
|
||||
_children.Insert(index, child);
|
||||
|
||||
// Propagate binding context to new child
|
||||
if (BindingContext != null)
|
||||
{
|
||||
SetInheritedBindingContext(child, BindingContext);
|
||||
}
|
||||
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +586,9 @@ public abstract class SkiaView : IDisposable
|
|||
public void Draw(SKCanvas canvas)
|
||||
{
|
||||
if (!IsVisible || Opacity <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.Save();
|
||||
|
||||
|
|
@ -338,8 +651,8 @@ public abstract class SkiaView : IDisposable
|
|||
/// </summary>
|
||||
protected virtual SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var width = RequestedWidth >= 0 ? (float)RequestedWidth : 0;
|
||||
var height = RequestedHeight >= 0 ? (float)RequestedHeight : 0;
|
||||
var width = WidthRequest >= 0 ? (float)WidthRequest : 0;
|
||||
var height = HeightRequest >= 0 ? (float)HeightRequest : 0;
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
|
|
@ -369,6 +682,7 @@ public abstract class SkiaView : IDisposable
|
|||
|
||||
/// <summary>
|
||||
/// Performs hit testing to find the view at the given coordinates.
|
||||
/// Coordinates are in absolute window space, matching how Bounds are stored.
|
||||
/// </summary>
|
||||
public virtual SkiaView? HitTest(float x, float y)
|
||||
{
|
||||
|
|
@ -379,11 +693,10 @@ public abstract class SkiaView : IDisposable
|
|||
return null;
|
||||
|
||||
// Check children in reverse order (top-most first)
|
||||
var localX = x - Bounds.Left;
|
||||
var localY = y - Bounds.Top;
|
||||
// Coordinates stay in absolute space since children have absolute Bounds
|
||||
for (int i = _children.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var hit = _children[i].HitTest(localX, localY);
|
||||
var hit = _children[i].HitTest(x, y);
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Visual State Manager for Skia-rendered controls.
|
||||
/// Provides state-based styling through XAML VisualStateGroups.
|
||||
/// </summary>
|
||||
public static class SkiaVisualStateManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Common visual state names.
|
||||
/// </summary>
|
||||
public static class CommonStates
|
||||
{
|
||||
public const string Normal = "Normal";
|
||||
public const string Disabled = "Disabled";
|
||||
public const string Focused = "Focused";
|
||||
public const string PointerOver = "PointerOver";
|
||||
public const string Pressed = "Pressed";
|
||||
public const string Selected = "Selected";
|
||||
public const string Checked = "Checked";
|
||||
public const string Unchecked = "Unchecked";
|
||||
public const string On = "On";
|
||||
public const string Off = "Off";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attached property for VisualStateGroups.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty VisualStateGroupsProperty =
|
||||
BindableProperty.CreateAttached(
|
||||
"VisualStateGroups",
|
||||
typeof(SkiaVisualStateGroupList),
|
||||
typeof(SkiaVisualStateManager),
|
||||
null,
|
||||
propertyChanged: OnVisualStateGroupsChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the visual state groups for the specified view.
|
||||
/// </summary>
|
||||
public static SkiaVisualStateGroupList? GetVisualStateGroups(SkiaView view)
|
||||
{
|
||||
return (SkiaVisualStateGroupList?)view.GetValue(VisualStateGroupsProperty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the visual state groups for the specified view.
|
||||
/// </summary>
|
||||
public static void SetVisualStateGroups(SkiaView view, SkiaVisualStateGroupList? value)
|
||||
{
|
||||
view.SetValue(VisualStateGroupsProperty, value);
|
||||
}
|
||||
|
||||
private static void OnVisualStateGroupsChanged(BindableObject bindable, object? oldValue, object? newValue)
|
||||
{
|
||||
if (bindable is SkiaView view && newValue is SkiaVisualStateGroupList groups)
|
||||
{
|
||||
// Initialize to default state
|
||||
GoToState(view, CommonStates.Normal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the view to the specified visual state.
|
||||
/// </summary>
|
||||
/// <param name="view">The view to transition.</param>
|
||||
/// <param name="stateName">The name of the state to transition to.</param>
|
||||
/// <returns>True if the state was found and applied, false otherwise.</returns>
|
||||
public static bool GoToState(SkiaView view, string stateName)
|
||||
{
|
||||
var groups = GetVisualStateGroups(view);
|
||||
if (groups == null || groups.Count == 0)
|
||||
return false;
|
||||
|
||||
bool stateFound = false;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Find the state in this group
|
||||
SkiaVisualState? targetState = null;
|
||||
foreach (var state in group.States)
|
||||
{
|
||||
if (state.Name == stateName)
|
||||
{
|
||||
targetState = state;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetState != null)
|
||||
{
|
||||
// Unapply current state if different
|
||||
if (group.CurrentState != null && group.CurrentState != targetState)
|
||||
{
|
||||
UnapplyState(view, group.CurrentState);
|
||||
}
|
||||
|
||||
// Apply new state
|
||||
ApplyState(view, targetState);
|
||||
group.CurrentState = targetState;
|
||||
stateFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
return stateFound;
|
||||
}
|
||||
|
||||
private static void ApplyState(SkiaView view, SkiaVisualState state)
|
||||
{
|
||||
foreach (var setter in state.Setters)
|
||||
{
|
||||
setter.Apply(view);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UnapplyState(SkiaView view, SkiaVisualState state)
|
||||
{
|
||||
foreach (var setter in state.Setters)
|
||||
{
|
||||
setter.Unapply(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A list of visual state groups.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateGroupList : List<SkiaVisualStateGroup>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A group of mutually exclusive visual states.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this group.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of states in this group.
|
||||
/// </summary>
|
||||
public List<SkiaVisualState> States { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the currently active state.
|
||||
/// </summary>
|
||||
public SkiaVisualState? CurrentState { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single visual state with its setters.
|
||||
/// </summary>
|
||||
public class SkiaVisualState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the name of this state.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of setters for this state.
|
||||
/// </summary>
|
||||
public List<SkiaVisualStateSetter> Setters { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a property value when a visual state is active.
|
||||
/// </summary>
|
||||
public class SkiaVisualStateSetter
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the property to set.
|
||||
/// </summary>
|
||||
public BindableProperty? Property { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value to set.
|
||||
/// </summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
// Store original value for unapply
|
||||
private object? _originalValue;
|
||||
private bool _hasOriginalValue;
|
||||
|
||||
/// <summary>
|
||||
/// Applies this setter to the target view.
|
||||
/// </summary>
|
||||
public void Apply(SkiaView view)
|
||||
{
|
||||
if (Property == null) return;
|
||||
|
||||
// Store original value if not already stored
|
||||
if (!_hasOriginalValue)
|
||||
{
|
||||
_originalValue = view.GetValue(Property);
|
||||
_hasOriginalValue = true;
|
||||
}
|
||||
|
||||
view.SetValue(Property, Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unapplies this setter, restoring the original value.
|
||||
/// </summary>
|
||||
public void Unapply(SkiaView view)
|
||||
{
|
||||
if (Property == null || !_hasOriginalValue) return;
|
||||
|
||||
view.SetValue(Property, _originalValue);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,695 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Microsoft.Maui.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// WebView implementation using WebKitGTK for Linux.
|
||||
/// Renders web content in a native GTK window and composites to Skia.
|
||||
/// </summary>
|
||||
public class SkiaWebView : SkiaView
|
||||
{
|
||||
#region Native Interop - GTK
|
||||
|
||||
private const string LibGtk4 = "libgtk-4.so.1";
|
||||
private const string LibGtk3 = "libgtk-3.so.0";
|
||||
private const string LibWebKit2Gtk4 = "libwebkitgtk-6.0.so.4";
|
||||
private const string LibWebKit2Gtk3 = "libwebkit2gtk-4.1.so.0";
|
||||
private const string LibGObject = "libgobject-2.0.so.0";
|
||||
private const string LibGLib = "libglib-2.0.so.0";
|
||||
|
||||
private static bool _useGtk4;
|
||||
private static bool _gtkInitialized;
|
||||
private static string _webkitLib = LibWebKit2Gtk3;
|
||||
|
||||
// GTK functions
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_init")]
|
||||
private static extern void gtk4_init();
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_init_check")]
|
||||
private static extern bool gtk3_init_check(ref int argc, ref IntPtr argv);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_new")]
|
||||
private static extern IntPtr gtk4_window_new();
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_window_new")]
|
||||
private static extern IntPtr gtk3_window_new(int type);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_set_default_size")]
|
||||
private static extern void gtk4_window_set_default_size(IntPtr window, int width, int height);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_window_set_default_size")]
|
||||
private static extern void gtk3_window_set_default_size(IntPtr window, int width, int height);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_window_set_child")]
|
||||
private static extern void gtk4_window_set_child(IntPtr window, IntPtr child);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_container_add")]
|
||||
private static extern void gtk3_container_add(IntPtr container, IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_show")]
|
||||
private static extern void gtk4_widget_show(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_widget_show_all")]
|
||||
private static extern void gtk3_widget_show_all(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_hide")]
|
||||
private static extern void gtk4_widget_hide(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk3, EntryPoint = "gtk_widget_hide")]
|
||||
private static extern void gtk3_widget_hide(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_get_width")]
|
||||
private static extern int gtk4_widget_get_width(IntPtr widget);
|
||||
|
||||
[DllImport(LibGtk4, EntryPoint = "gtk_widget_get_height")]
|
||||
private static extern int gtk4_widget_get_height(IntPtr widget);
|
||||
|
||||
// GObject
|
||||
[DllImport(LibGObject, EntryPoint = "g_object_unref")]
|
||||
private static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
[DllImport(LibGObject, EntryPoint = "g_signal_connect_data")]
|
||||
private static extern ulong g_signal_connect_data(IntPtr instance,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string signal,
|
||||
IntPtr handler, IntPtr data, IntPtr destroyData, int flags);
|
||||
|
||||
// GLib main loop (for event processing)
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_context_iteration")]
|
||||
private static extern bool g_main_context_iteration(IntPtr context, bool mayBlock);
|
||||
|
||||
#endregion
|
||||
|
||||
#region WebKit Functions
|
||||
|
||||
// We'll load these dynamically based on available version
|
||||
private delegate IntPtr WebKitWebViewNewDelegate();
|
||||
private delegate void WebKitWebViewLoadUriDelegate(IntPtr webView, [MarshalAs(UnmanagedType.LPStr)] string uri);
|
||||
private delegate void WebKitWebViewLoadHtmlDelegate(IntPtr webView, [MarshalAs(UnmanagedType.LPStr)] string html, [MarshalAs(UnmanagedType.LPStr)] string? baseUri);
|
||||
private delegate IntPtr WebKitWebViewGetUriDelegate(IntPtr webView);
|
||||
private delegate IntPtr WebKitWebViewGetTitleDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewGoBackDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewGoForwardDelegate(IntPtr webView);
|
||||
private delegate bool WebKitWebViewCanGoBackDelegate(IntPtr webView);
|
||||
private delegate bool WebKitWebViewCanGoForwardDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewReloadDelegate(IntPtr webView);
|
||||
private delegate void WebKitWebViewStopLoadingDelegate(IntPtr webView);
|
||||
private delegate double WebKitWebViewGetEstimatedLoadProgressDelegate(IntPtr webView);
|
||||
private delegate IntPtr WebKitWebViewGetSettingsDelegate(IntPtr webView);
|
||||
private delegate void WebKitSettingsSetEnableJavascriptDelegate(IntPtr settings, bool enabled);
|
||||
|
||||
private static WebKitWebViewNewDelegate? _webkitWebViewNew;
|
||||
private static WebKitWebViewLoadUriDelegate? _webkitLoadUri;
|
||||
private static WebKitWebViewLoadHtmlDelegate? _webkitLoadHtml;
|
||||
private static WebKitWebViewGetUriDelegate? _webkitGetUri;
|
||||
private static WebKitWebViewGetTitleDelegate? _webkitGetTitle;
|
||||
private static WebKitWebViewGoBackDelegate? _webkitGoBack;
|
||||
private static WebKitWebViewGoForwardDelegate? _webkitGoForward;
|
||||
private static WebKitWebViewCanGoBackDelegate? _webkitCanGoBack;
|
||||
private static WebKitWebViewCanGoForwardDelegate? _webkitCanGoForward;
|
||||
private static WebKitWebViewReloadDelegate? _webkitReload;
|
||||
private static WebKitWebViewStopLoadingDelegate? _webkitStopLoading;
|
||||
private static WebKitWebViewGetEstimatedLoadProgressDelegate? _webkitGetProgress;
|
||||
private static WebKitWebViewGetSettingsDelegate? _webkitGetSettings;
|
||||
private static WebKitSettingsSetEnableJavascriptDelegate? _webkitSetJavascript;
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlopen([MarshalAs(UnmanagedType.LPStr)] string? filename, int flags);
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlsym(IntPtr handle, [MarshalAs(UnmanagedType.LPStr)] string symbol);
|
||||
|
||||
[DllImport("libdl.so.2")]
|
||||
private static extern IntPtr dlerror();
|
||||
|
||||
private const int RTLD_NOW = 2;
|
||||
private const int RTLD_GLOBAL = 0x100;
|
||||
|
||||
private static IntPtr _webkitHandle;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private IntPtr _gtkWindow;
|
||||
private IntPtr _webView;
|
||||
private string _source = "";
|
||||
private string _html = "";
|
||||
private bool _isInitialized;
|
||||
private bool _javascriptEnabled = true;
|
||||
private double _loadProgress;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URL to navigate to.
|
||||
/// </summary>
|
||||
public string Source
|
||||
{
|
||||
get => _source;
|
||||
set
|
||||
{
|
||||
if (_source != value)
|
||||
{
|
||||
_source = value;
|
||||
if (_isInitialized && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
LoadUrl(value);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HTML content to display.
|
||||
/// </summary>
|
||||
public string Html
|
||||
{
|
||||
get => _html;
|
||||
set
|
||||
{
|
||||
if (_html != value)
|
||||
{
|
||||
_html = value;
|
||||
if (_isInitialized && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
LoadHtml(value);
|
||||
}
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the WebView can navigate back.
|
||||
/// </summary>
|
||||
public bool CanGoBack => _webView != IntPtr.Zero && _webkitCanGoBack?.Invoke(_webView) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the WebView can navigate forward.
|
||||
/// </summary>
|
||||
public bool CanGoForward => _webView != IntPtr.Zero && _webkitCanGoForward?.Invoke(_webView) == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current URL.
|
||||
/// </summary>
|
||||
public string? CurrentUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetUri == null) return null;
|
||||
var ptr = _webkitGetUri(_webView);
|
||||
return ptr != IntPtr.Zero ? Marshal.PtrToStringAnsi(ptr) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current page title.
|
||||
/// </summary>
|
||||
public string? Title
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetTitle == null) return null;
|
||||
var ptr = _webkitGetTitle(_webView);
|
||||
return ptr != IntPtr.Zero ? Marshal.PtrToStringAnsi(ptr) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether JavaScript is enabled.
|
||||
/// </summary>
|
||||
public bool JavaScriptEnabled
|
||||
{
|
||||
get => _javascriptEnabled;
|
||||
set
|
||||
{
|
||||
_javascriptEnabled = value;
|
||||
UpdateJavaScriptSetting();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the load progress (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double LoadProgress => _loadProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether WebKit is available on this system.
|
||||
/// </summary>
|
||||
public static bool IsSupported => InitializeWebKit();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
public event EventHandler<WebNavigatingEventArgs>? Navigating;
|
||||
public event EventHandler<WebNavigatedEventArgs>? Navigated;
|
||||
public event EventHandler<string>? TitleChanged;
|
||||
public event EventHandler<double>? LoadProgressChanged;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
public SkiaWebView()
|
||||
{
|
||||
RequestedWidth = 400;
|
||||
RequestedHeight = 300;
|
||||
BackgroundColor = SKColors.White;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
|
||||
private static bool InitializeWebKit()
|
||||
{
|
||||
if (_webkitHandle != IntPtr.Zero) return true;
|
||||
|
||||
// Try WebKitGTK 6.0 (GTK4) first
|
||||
_webkitHandle = dlopen(LibWebKit2Gtk4, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = true;
|
||||
_webkitLib = LibWebKit2Gtk4;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to WebKitGTK 4.1 (GTK3)
|
||||
_webkitHandle = dlopen(LibWebKit2Gtk3, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = false;
|
||||
_webkitLib = LibWebKit2Gtk3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try older WebKitGTK 4.0
|
||||
_webkitHandle = dlopen("libwebkit2gtk-4.0.so.37", RTLD_NOW | RTLD_GLOBAL);
|
||||
if (_webkitHandle != IntPtr.Zero)
|
||||
{
|
||||
_useGtk4 = false;
|
||||
_webkitLib = "libwebkit2gtk-4.0.so.37";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_webkitHandle == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("[WebView] WebKitGTK not found. Install with: sudo apt install libwebkit2gtk-4.1-0");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load function pointers
|
||||
_webkitWebViewNew = LoadFunction<WebKitWebViewNewDelegate>("webkit_web_view_new");
|
||||
_webkitLoadUri = LoadFunction<WebKitWebViewLoadUriDelegate>("webkit_web_view_load_uri");
|
||||
_webkitLoadHtml = LoadFunction<WebKitWebViewLoadHtmlDelegate>("webkit_web_view_load_html");
|
||||
_webkitGetUri = LoadFunction<WebKitWebViewGetUriDelegate>("webkit_web_view_get_uri");
|
||||
_webkitGetTitle = LoadFunction<WebKitWebViewGetTitleDelegate>("webkit_web_view_get_title");
|
||||
_webkitGoBack = LoadFunction<WebKitWebViewGoBackDelegate>("webkit_web_view_go_back");
|
||||
_webkitGoForward = LoadFunction<WebKitWebViewGoForwardDelegate>("webkit_web_view_go_forward");
|
||||
_webkitCanGoBack = LoadFunction<WebKitWebViewCanGoBackDelegate>("webkit_web_view_can_go_back");
|
||||
_webkitCanGoForward = LoadFunction<WebKitWebViewCanGoForwardDelegate>("webkit_web_view_can_go_forward");
|
||||
_webkitReload = LoadFunction<WebKitWebViewReloadDelegate>("webkit_web_view_reload");
|
||||
_webkitStopLoading = LoadFunction<WebKitWebViewStopLoadingDelegate>("webkit_web_view_stop_loading");
|
||||
_webkitGetProgress = LoadFunction<WebKitWebViewGetEstimatedLoadProgressDelegate>("webkit_web_view_get_estimated_load_progress");
|
||||
_webkitGetSettings = LoadFunction<WebKitWebViewGetSettingsDelegate>("webkit_web_view_get_settings");
|
||||
_webkitSetJavascript = LoadFunction<WebKitSettingsSetEnableJavascriptDelegate>("webkit_settings_set_enable_javascript");
|
||||
|
||||
Console.WriteLine($"[WebView] Using {_webkitLib}");
|
||||
return _webkitWebViewNew != null;
|
||||
}
|
||||
|
||||
private static T? LoadFunction<T>(string name) where T : Delegate
|
||||
{
|
||||
var ptr = dlsym(_webkitHandle, name);
|
||||
if (ptr == IntPtr.Zero) return null;
|
||||
return Marshal.GetDelegateForFunctionPointer<T>(ptr);
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
if (!InitializeWebKit()) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Initialize GTK if needed
|
||||
if (!_gtkInitialized)
|
||||
{
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_init();
|
||||
}
|
||||
else
|
||||
{
|
||||
int argc = 0;
|
||||
IntPtr argv = IntPtr.Zero;
|
||||
gtk3_init_check(ref argc, ref argv);
|
||||
}
|
||||
_gtkInitialized = true;
|
||||
}
|
||||
|
||||
// Create WebKit view
|
||||
_webView = _webkitWebViewNew!();
|
||||
if (_webView == IntPtr.Zero)
|
||||
{
|
||||
Console.WriteLine("[WebView] Failed to create WebKit view");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create GTK window to host the WebView
|
||||
if (_useGtk4)
|
||||
{
|
||||
_gtkWindow = gtk4_window_new();
|
||||
gtk4_window_set_default_size(_gtkWindow, (int)RequestedWidth, (int)RequestedHeight);
|
||||
gtk4_window_set_child(_gtkWindow, _webView);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gtkWindow = gtk3_window_new(0); // GTK_WINDOW_TOPLEVEL
|
||||
gtk3_window_set_default_size(_gtkWindow, (int)RequestedWidth, (int)RequestedHeight);
|
||||
gtk3_container_add(_gtkWindow, _webView);
|
||||
}
|
||||
|
||||
UpdateJavaScriptSetting();
|
||||
_isInitialized = true;
|
||||
|
||||
// Load initial content
|
||||
if (!string.IsNullOrEmpty(_source))
|
||||
{
|
||||
LoadUrl(_source);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_html))
|
||||
{
|
||||
LoadHtml(_html);
|
||||
}
|
||||
|
||||
Console.WriteLine("[WebView] Initialized successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WebView] Initialization failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Navigation
|
||||
|
||||
public void LoadUrl(string url)
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_webView == IntPtr.Zero || _webkitLoadUri == null) return;
|
||||
|
||||
Navigating?.Invoke(this, new WebNavigatingEventArgs(url));
|
||||
_webkitLoadUri(_webView, url);
|
||||
}
|
||||
|
||||
public void LoadHtml(string html, string? baseUrl = null)
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_webView == IntPtr.Zero || _webkitLoadHtml == null) return;
|
||||
|
||||
_webkitLoadHtml(_webView, html, baseUrl);
|
||||
}
|
||||
|
||||
public void GoBack()
|
||||
{
|
||||
if (_webView != IntPtr.Zero && CanGoBack)
|
||||
{
|
||||
_webkitGoBack?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void GoForward()
|
||||
{
|
||||
if (_webView != IntPtr.Zero && CanGoForward)
|
||||
{
|
||||
_webkitGoForward?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (_webView != IntPtr.Zero)
|
||||
{
|
||||
_webkitReload?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_webView != IntPtr.Zero)
|
||||
{
|
||||
_webkitStopLoading?.Invoke(_webView);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateJavaScriptSetting()
|
||||
{
|
||||
if (_webView == IntPtr.Zero || _webkitGetSettings == null || _webkitSetJavascript == null) return;
|
||||
|
||||
var settings = _webkitGetSettings(_webView);
|
||||
if (settings != IntPtr.Zero)
|
||||
{
|
||||
_webkitSetJavascript(settings, _javascriptEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Processing
|
||||
|
||||
/// <summary>
|
||||
/// Process pending GTK events. Call this from your main loop.
|
||||
/// </summary>
|
||||
public void ProcessEvents()
|
||||
{
|
||||
if (!_isInitialized) return;
|
||||
|
||||
// Process GTK events
|
||||
g_main_context_iteration(IntPtr.Zero, false);
|
||||
|
||||
// Update progress
|
||||
if (_webView != IntPtr.Zero && _webkitGetProgress != null)
|
||||
{
|
||||
var progress = _webkitGetProgress(_webView);
|
||||
if (Math.Abs(progress - _loadProgress) > 0.01)
|
||||
{
|
||||
_loadProgress = progress;
|
||||
LoadProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the native WebView window (for testing/debugging).
|
||||
/// </summary>
|
||||
public void ShowNativeWindow()
|
||||
{
|
||||
if (!_isInitialized) Initialize();
|
||||
if (_gtkWindow == IntPtr.Zero) return;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_show(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_show_all(_gtkWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hide the native WebView window.
|
||||
/// </summary>
|
||||
public void HideNativeWindow()
|
||||
{
|
||||
if (_gtkWindow == IntPtr.Zero) return;
|
||||
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_hide(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_hide(_gtkWindow);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rendering
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
base.OnDraw(canvas, bounds);
|
||||
|
||||
// Draw placeholder/loading state
|
||||
using var bgPaint = new SKPaint { Color = BackgroundColor, Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRect(bounds, bgPaint);
|
||||
|
||||
// Draw border
|
||||
using var borderPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(200, 200, 200),
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 1
|
||||
};
|
||||
canvas.DrawRect(bounds, borderPaint);
|
||||
|
||||
// Draw web icon and status
|
||||
var centerX = bounds.MidX;
|
||||
var centerY = bounds.MidY;
|
||||
|
||||
// Globe icon
|
||||
using var iconPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(100, 100, 100),
|
||||
Style = SKPaintStyle.Stroke,
|
||||
StrokeWidth = 2,
|
||||
IsAntialias = true
|
||||
};
|
||||
canvas.DrawCircle(centerX, centerY - 20, 25, iconPaint);
|
||||
canvas.DrawLine(centerX - 25, centerY - 20, centerX + 25, centerY - 20, iconPaint);
|
||||
canvas.DrawArc(new SKRect(centerX - 15, centerY - 45, centerX + 15, centerY + 5), 0, 180, false, iconPaint);
|
||||
|
||||
// Status text
|
||||
using var textPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(80, 80, 80),
|
||||
IsAntialias = true,
|
||||
TextSize = 14
|
||||
};
|
||||
|
||||
string statusText;
|
||||
if (!IsSupported)
|
||||
{
|
||||
statusText = "WebKitGTK not installed";
|
||||
}
|
||||
else if (_isInitialized)
|
||||
{
|
||||
statusText = string.IsNullOrEmpty(_source) ? "No URL loaded" : $"Loading: {_source}";
|
||||
if (_loadProgress > 0 && _loadProgress < 1)
|
||||
{
|
||||
statusText = $"Loading: {(int)(_loadProgress * 100)}%";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
statusText = "WebView (click to open)";
|
||||
}
|
||||
|
||||
var textWidth = textPaint.MeasureText(statusText);
|
||||
canvas.DrawText(statusText, centerX - textWidth / 2, centerY + 30, textPaint);
|
||||
|
||||
// Draw install hint if not supported
|
||||
if (!IsSupported)
|
||||
{
|
||||
using var hintPaint = new SKPaint
|
||||
{
|
||||
Color = new SKColor(120, 120, 120),
|
||||
IsAntialias = true,
|
||||
TextSize = 11
|
||||
};
|
||||
var hint = "Install: sudo apt install libwebkit2gtk-4.1-0";
|
||||
var hintWidth = hintPaint.MeasureText(hint);
|
||||
canvas.DrawText(hint, centerX - hintWidth / 2, centerY + 50, hintPaint);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
if (_loadProgress > 0 && _loadProgress < 1)
|
||||
{
|
||||
var progressRect = new SKRect(bounds.Left + 20, bounds.Bottom - 30, bounds.Right - 20, bounds.Bottom - 20);
|
||||
using var progressBgPaint = new SKPaint { Color = new SKColor(230, 230, 230), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(progressRect, 5), progressBgPaint);
|
||||
|
||||
var filledWidth = progressRect.Width * (float)_loadProgress;
|
||||
var filledRect = new SKRect(progressRect.Left, progressRect.Top, progressRect.Left + filledWidth, progressRect.Bottom);
|
||||
using var progressPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Fill };
|
||||
canvas.DrawRoundRect(new SKRoundRect(filledRect, 5), progressPaint);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
|
||||
if (!_isInitialized && IsSupported)
|
||||
{
|
||||
Initialize();
|
||||
ShowNativeWindow();
|
||||
}
|
||||
else if (_isInitialized)
|
||||
{
|
||||
ShowNativeWindow();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_gtkWindow != IntPtr.Zero)
|
||||
{
|
||||
if (_useGtk4)
|
||||
{
|
||||
gtk4_widget_hide(_gtkWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
gtk3_widget_hide(_gtkWindow);
|
||||
}
|
||||
g_object_unref(_gtkWindow);
|
||||
_gtkWindow = IntPtr.Zero;
|
||||
}
|
||||
_webView = IntPtr.Zero;
|
||||
_isInitialized = false;
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Event Args
|
||||
|
||||
public class WebNavigatingEventArgs : EventArgs
|
||||
{
|
||||
public string Url { get; }
|
||||
public bool Cancel { get; set; }
|
||||
|
||||
public WebNavigatingEventArgs(string url)
|
||||
{
|
||||
Url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public class WebNavigatedEventArgs : EventArgs
|
||||
{
|
||||
public string Url { get; }
|
||||
public bool Success { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
public WebNavigatedEventArgs(string url, bool success, string? error = null)
|
||||
{
|
||||
Url = url;
|
||||
Success = success;
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -288,8 +288,12 @@ public class X11Window : IDisposable
|
|||
|
||||
KeyDown?.Invoke(this, new KeyEventArgs(key, modifiers));
|
||||
|
||||
// Generate text input for printable characters
|
||||
if (keysym >= 32 && keysym <= 126)
|
||||
// Generate text input for printable characters, but NOT when Control or Alt is held
|
||||
// (those are keyboard shortcuts, not text input)
|
||||
bool isControlHeld = (keyEvent.State & 0x04) != 0; // ControlMask
|
||||
bool isAltHeld = (keyEvent.State & 0x08) != 0; // Mod1Mask (Alt)
|
||||
|
||||
if (keysym >= 32 && keysym <= 126 && !isControlHeld && !isAltHeld)
|
||||
{
|
||||
TextInput?.Invoke(this, new TextInputEventArgs(((char)keysym).ToString()));
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
|
|
@ -0,0 +1,307 @@
|
|||
# Frequently Asked Questions
|
||||
|
||||
## Visual Studio Integration
|
||||
|
||||
### How do I add Linux support to my existing MAUI project?
|
||||
|
||||
Unlike Android, iOS, and Windows which appear in Visual Studio's platform dropdown, Linux requires manual configuration since it's a community platform.
|
||||
|
||||
**Step 1: Add the NuGet Package**
|
||||
|
||||
```bash
|
||||
dotnet add package OpenMaui.Controls.Linux --prerelease
|
||||
```
|
||||
|
||||
Or in Visual Studio: Right-click project → Manage NuGet Packages → Search "OpenMaui.Controls.Linux"
|
||||
|
||||
**Step 2: Create a Linux Startup Project**
|
||||
|
||||
Create a new folder called `Platforms/Linux` in your project and add a `Program.cs`:
|
||||
|
||||
```csharp
|
||||
using OpenMaui.Platform.Linux;
|
||||
|
||||
namespace MyApp.Platforms.Linux;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var app = new LinuxApplication();
|
||||
app.MainPage = new MainPage(); // Your existing MainPage
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add a Linux Build Configuration**
|
||||
|
||||
Add to your `.csproj`:
|
||||
|
||||
```xml
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Debug|net9.0'">
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
Or create a separate `MyApp.Linux.csproj` that references your shared code.
|
||||
|
||||
---
|
||||
|
||||
### Why doesn't Linux appear in Visual Studio's platform dropdown?
|
||||
|
||||
Visual Studio's MAUI tooling only shows platforms officially supported by Microsoft (.NET MAUI team). Linux is a community-supported platform through OpenMaui.
|
||||
|
||||
**Workarounds:**
|
||||
|
||||
1. **Use a separate Linux project** - Create a dedicated `MyApp.Linux` console project
|
||||
2. **Use VS Code** - Better cross-platform support with command-line builds
|
||||
3. **Use JetBrains Rider** - Excellent Linux and cross-platform support
|
||||
4. **Command line** - `dotnet build -r linux-x64` works from any IDE
|
||||
|
||||
---
|
||||
|
||||
### How do I build for Linux from Visual Studio on Windows?
|
||||
|
||||
**Option A: Command Line (Recommended)**
|
||||
|
||||
Open Terminal in VS and run:
|
||||
```bash
|
||||
dotnet build -c Release -r linux-x64
|
||||
dotnet publish -c Release -r linux-x64 --self-contained
|
||||
```
|
||||
|
||||
**Option B: Custom Build Profile**
|
||||
|
||||
1. Right-click solution → Properties → Configuration Manager
|
||||
2. Create a new configuration called "Linux"
|
||||
3. Edit project properties to set RuntimeIdentifier for this configuration
|
||||
|
||||
**Option C: WSL Integration**
|
||||
|
||||
If you have WSL (Windows Subsystem for Linux) installed:
|
||||
```bash
|
||||
wsl dotnet build
|
||||
wsl dotnet run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### How do I debug Linux apps from Windows?
|
||||
|
||||
**Option 1: Remote Debugging**
|
||||
|
||||
1. Install `vsdbg` on your Linux machine
|
||||
2. Configure remote debugging in VS/VS Code
|
||||
3. Attach to the running process
|
||||
|
||||
**Option 2: WSL (Recommended for development)**
|
||||
|
||||
1. Install WSL 2 with Ubuntu
|
||||
2. Install .NET SDK in WSL
|
||||
3. Run your app in WSL with X11 forwarding:
|
||||
```bash
|
||||
export DISPLAY=:0
|
||||
dotnet run
|
||||
```
|
||||
4. Use WSLg (Windows 11) for native GUI support
|
||||
|
||||
**Option 3: Virtual Machine**
|
||||
|
||||
1. Set up a Linux VM (VMware, VirtualBox, Hyper-V)
|
||||
2. Share your project folder
|
||||
3. Build and run inside the VM
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### What's the recommended project structure for cross-platform MAUI with Linux?
|
||||
|
||||
```
|
||||
MyApp/
|
||||
├── MyApp.sln
|
||||
├── MyApp/ # Shared MAUI project
|
||||
│ ├── MyApp.csproj
|
||||
│ ├── App.xaml
|
||||
│ ├── MainPage.xaml
|
||||
│ ├── Platforms/
|
||||
│ │ ├── Android/
|
||||
│ │ ├── iOS/
|
||||
│ │ ├── Windows/
|
||||
│ │ └── Linux/ # Add this folder
|
||||
│ │ └── Program.cs
|
||||
│ └── ...
|
||||
└── MyApp.Linux/ # Optional: Separate Linux project
|
||||
├── MyApp.Linux.csproj
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
### Should I use a separate project or add Linux to my existing project?
|
||||
|
||||
**Add to existing project** if:
|
||||
- You want a single codebase
|
||||
- Your MAUI code is mostly XAML-based
|
||||
- You're comfortable with conditional compilation
|
||||
|
||||
**Use a separate project** if:
|
||||
- You want cleaner separation
|
||||
- You need Linux-specific features
|
||||
- You want independent build/deploy cycles
|
||||
- Your team includes Linux specialists
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
### How do I create a Linux executable?
|
||||
|
||||
```bash
|
||||
# Self-contained (includes .NET runtime)
|
||||
dotnet publish -c Release -r linux-x64 --self-contained -o ./publish
|
||||
|
||||
# Framework-dependent (smaller, requires .NET on target)
|
||||
dotnet publish -c Release -r linux-x64 --no-self-contained -o ./publish
|
||||
```
|
||||
|
||||
For ARM64 (Raspberry Pi, etc.):
|
||||
```bash
|
||||
dotnet publish -c Release -r linux-arm64 --self-contained -o ./publish
|
||||
```
|
||||
|
||||
### How do I create a .deb or .rpm package?
|
||||
|
||||
We recommend using packaging tools:
|
||||
|
||||
**For .deb (Debian/Ubuntu):**
|
||||
```bash
|
||||
dotnet tool install -g dotnet-deb
|
||||
dotnet deb -c Release -r linux-x64
|
||||
```
|
||||
|
||||
**For .rpm (Fedora/RHEL):**
|
||||
```bash
|
||||
dotnet tool install -g dotnet-rpm
|
||||
dotnet rpm -c Release -r linux-x64
|
||||
```
|
||||
|
||||
**For AppImage (Universal):**
|
||||
Use [AppImageKit](https://appimage.org/) with your published output.
|
||||
|
||||
**For Flatpak:**
|
||||
Create a flatpak manifest and build with `flatpak-builder`.
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "SkiaSharp native library not found"
|
||||
|
||||
Install the required native libraries:
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install libfontconfig1 libfreetype6
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install fontconfig freetype
|
||||
```
|
||||
|
||||
### "Cannot open display" or "No display server"
|
||||
|
||||
Ensure X11 or Wayland is running:
|
||||
```bash
|
||||
echo $DISPLAY # Should show :0 or similar
|
||||
echo $WAYLAND_DISPLAY # For Wayland
|
||||
```
|
||||
|
||||
For headless/SSH sessions:
|
||||
```bash
|
||||
export DISPLAY=:0 # If X11 is running
|
||||
```
|
||||
|
||||
### "libX11.so not found"
|
||||
|
||||
Install X11 development libraries:
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install libx11-6 libx11-dev
|
||||
```
|
||||
|
||||
### App runs but window doesn't appear
|
||||
|
||||
Check if you're running under Wayland without XWayland:
|
||||
```bash
|
||||
# Force X11 mode
|
||||
export GDK_BACKEND=x11
|
||||
./MyApp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IDE Recommendations
|
||||
|
||||
### What's the best IDE for developing MAUI apps with Linux support?
|
||||
|
||||
| IDE | Linux Dev | Windows Dev | Pros | Cons |
|
||||
|-----|-----------|-------------|------|------|
|
||||
| **VS Code** | ⭐⭐⭐ | ⭐⭐⭐ | Cross-platform, lightweight, great C# extension | No visual XAML designer |
|
||||
| **JetBrains Rider** | ⭐⭐⭐ | ⭐⭐⭐ | Excellent cross-platform, powerful refactoring | Paid license |
|
||||
| **Visual Studio** | ⭐ | ⭐⭐⭐ | Best MAUI tooling on Windows | No native Linux support |
|
||||
| **Visual Studio Mac** | ⭐⭐ | N/A | Good MAUI support | macOS only |
|
||||
|
||||
**Our recommendation:**
|
||||
- **Windows developers:** Visual Studio for Android/iOS/Windows, VS Code or command line for Linux builds
|
||||
- **Linux developers:** JetBrains Rider or VS Code
|
||||
- **Cross-platform teams:** VS Code with standardized build scripts
|
||||
|
||||
---
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### How do I set up CI/CD for Linux builds?
|
||||
|
||||
**GitHub Actions example:**
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libx11-dev libfontconfig1-dev
|
||||
|
||||
- name: Build
|
||||
run: dotnet build -c Release
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish -c Release -r linux-x64 --self-contained -o ./publish
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-app
|
||||
path: ./publish
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **GitHub Issues:** https://github.com/open-maui/maui-linux/issues
|
||||
- **Discussions:** https://github.com/open-maui/maui-linux/discussions
|
||||
- **Documentation:** https://github.com/open-maui/maui-linux/tree/main/docs
|
||||
|
||||
---
|
||||
|
||||
*Developed by [MarketAlly LLC](https://marketally.com) • Lead Architect: David H. Friedel Jr.*
|
||||
|
|
@ -31,7 +31,7 @@ sudo pacman -S libx11 libxrandr libxcursor libxi mesa fontconfig
|
|||
|
||||
1. Install the template:
|
||||
```bash
|
||||
dotnet new install Microsoft.Maui.Linux.Templates
|
||||
dotnet new install OpenMaui.Linux.Templates
|
||||
```
|
||||
|
||||
2. Create a new project:
|
||||
|
|
@ -55,13 +55,13 @@ cd MyMauiLinuxApp
|
|||
|
||||
2. Add the NuGet package:
|
||||
```bash
|
||||
dotnet add package Microsoft.Maui.Controls.Linux --prerelease
|
||||
dotnet add package OpenMaui.Controls.Linux --prerelease
|
||||
```
|
||||
|
||||
3. Update your `Program.cs`:
|
||||
```csharp
|
||||
using Microsoft.Maui.Platform;
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
using OpenMaui.Platform.Linux;
|
||||
|
||||
var app = new LinuxApplication();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# OpenMaui Linux Platform Roadmap
|
||||
|
||||
This document outlines the development roadmap for the OpenMaui Linux platform.
|
||||
|
||||
## Version 1.0 (Current - Preview)
|
||||
|
||||
### Completed Features ✅
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Core Control Library | ✅ Complete | 35+ controls including Button, Label, Entry, etc. |
|
||||
| SkiaSharp Rendering | ✅ Complete | Hardware-accelerated 2D graphics |
|
||||
| X11 Support | ✅ Complete | Full X11 display server integration |
|
||||
| Platform Services | ✅ Complete | Clipboard, file picker, notifications, etc. |
|
||||
| Accessibility (AT-SPI2) | ✅ Complete | Screen reader support |
|
||||
| Input Methods | ✅ Complete | IBus and XIM support |
|
||||
| High DPI Support | ✅ Complete | Automatic scale factor detection |
|
||||
| Drag and Drop | ✅ Complete | XDND protocol implementation |
|
||||
| Global Hotkeys | ✅ Complete | System-wide keyboard shortcuts |
|
||||
| XAML Support | ✅ Complete | Standard .NET MAUI XAML syntax |
|
||||
| Project Templates | ✅ Complete | Code and XAML-based templates |
|
||||
| Visual Studio Extension | ✅ Complete | Project templates and launch profiles |
|
||||
|
||||
## Version 1.1 (Next Release)
|
||||
|
||||
### In Progress 🚧
|
||||
|
||||
| Feature | Priority | Description |
|
||||
|---------|----------|-------------|
|
||||
| Complete Wayland Support | High | Full Wayland compositor support |
|
||||
| XAML Hot Reload | High | Live XAML editing during debugging |
|
||||
| Performance Optimizations | Medium | Rendering and memory improvements |
|
||||
|
||||
### Planned 📋
|
||||
|
||||
| Feature | Priority | Description |
|
||||
|---------|----------|-------------|
|
||||
| Hardware Video Acceleration | Medium | VA-API/VDPAU integration |
|
||||
| Live Visual Tree | Medium | Debug tool for inspecting UI hierarchy |
|
||||
| Theming Improvements | Medium | Better system theme integration |
|
||||
|
||||
## Version 1.2 (Future)
|
||||
|
||||
### Planned 📋
|
||||
|
||||
| Feature | Priority | Description |
|
||||
|---------|----------|-------------|
|
||||
| GTK4 Interop Layer | Low | Native GTK dialog support |
|
||||
| WebView Control | Medium | Embedded web browser support |
|
||||
| Maps Integration | Low | OpenStreetMap-based mapping |
|
||||
| Printing Support | Medium | CUPS printing integration |
|
||||
|
||||
## Version 2.0 (Long-term)
|
||||
|
||||
### Vision 🔮
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Vulkan Rendering | Next-gen graphics API support |
|
||||
| Flatpak Packaging | Easy distribution via Flatpak |
|
||||
| Snap Packaging | Ubuntu Snap store support |
|
||||
| AppImage Support | Portable Linux app format |
|
||||
| Multi-window Support | Multiple top-level windows |
|
||||
| System Tray Menus | Rich tray icon interactions |
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Priority areas:
|
||||
|
||||
1. **Wayland Support** - Help complete the Wayland backend
|
||||
2. **Testing** - Integration tests on various distributions
|
||||
3. **Documentation** - API docs and tutorials
|
||||
4. **Controls** - Additional control implementations
|
||||
5. **Samples** - Real-world demo applications
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) for details.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Target | Status |
|
||||
|-----------|--------|--------|
|
||||
| v1.0.0-preview.1 | Q1 2025 | ✅ Released |
|
||||
| v1.0.0-preview.2 | Q1 2025 | ✅ Released |
|
||||
| v1.0.0 | Q2 2025 | 🚧 In Progress |
|
||||
| v1.1.0 | Q3 2025 | 📋 Planned |
|
||||
| v1.2.0 | Q4 2025 | 📋 Planned |
|
||||
|
||||
## Feedback
|
||||
|
||||
- GitHub Issues: https://github.com/open-maui/maui-linux/issues
|
||||
- Discussions: https://github.com/open-maui/maui-linux/discussions
|
||||
|
||||
---
|
||||
|
||||
*Last updated: January 2025*
|
||||
*Copyright 2025 MarketAlly LLC*
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Microsoft.Maui.Controls.Linux.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,797 +0,0 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Maui.Platform;
|
||||
using SkiaSharp;
|
||||
|
||||
var demo = new AllControlsDemo();
|
||||
demo.Run();
|
||||
|
||||
class AllControlsDemo
|
||||
{
|
||||
private IntPtr _display, _window, _gc;
|
||||
private int _screen, _width = 1024, _height = 768;
|
||||
private bool _running = true;
|
||||
private IntPtr _wmDeleteMessage, _pixelBuffer = IntPtr.Zero;
|
||||
private int _bufferSize = 0;
|
||||
|
||||
private SkiaScrollView _scrollView = null!;
|
||||
private SkiaStackLayout _rootLayout = null!;
|
||||
private SkiaView? _pressedView = null;
|
||||
private SkiaView? _focusedView = null;
|
||||
private SkiaCollectionView _collectionView = null!;
|
||||
private SkiaDatePicker _datePicker = null!;
|
||||
private SkiaTimePicker _timePicker = null!;
|
||||
private SkiaPicker _picker = null!;
|
||||
private SkiaEntry _entry = null!;
|
||||
private SkiaSearchBar _searchBar = null!;
|
||||
private DateTime _lastMotionRender = DateTime.MinValue;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
try { InitializeX11(); CreateUI(); RunEventLoop(); }
|
||||
catch (Exception ex) { Console.WriteLine($"Error: {ex}"); }
|
||||
finally { Cleanup(); }
|
||||
}
|
||||
|
||||
private void InitializeX11()
|
||||
{
|
||||
_display = XOpenDisplay(IntPtr.Zero);
|
||||
if (_display == IntPtr.Zero) throw new Exception("Cannot open X11 display");
|
||||
_screen = XDefaultScreen(_display);
|
||||
var root = XRootWindow(_display, _screen);
|
||||
_window = XCreateSimpleWindow(_display, root, 50, 50, (uint)_width, (uint)_height, 1,
|
||||
XBlackPixel(_display, _screen), XWhitePixel(_display, _screen));
|
||||
XStoreName(_display, _window, "MAUI Linux Demo - All Controls");
|
||||
XSelectInput(_display, _window, ExposureMask | KeyPressMask | KeyReleaseMask |
|
||||
ButtonPressMask | ButtonReleaseMask | PointerMotionMask | StructureNotifyMask);
|
||||
_gc = XCreateGC(_display, _window, 0, IntPtr.Zero);
|
||||
_wmDeleteMessage = XInternAtom(_display, "WM_DELETE_WINDOW", false);
|
||||
XSetWMProtocols(_display, _window, ref _wmDeleteMessage, 1);
|
||||
EnsurePixelBuffer(_width, _height);
|
||||
XMapWindow(_display, _window);
|
||||
XFlush(_display);
|
||||
}
|
||||
|
||||
private void EnsurePixelBuffer(int w, int h)
|
||||
{
|
||||
int needed = w * h * 4;
|
||||
if (_pixelBuffer == IntPtr.Zero || _bufferSize < needed) {
|
||||
if (_pixelBuffer != IntPtr.Zero) Marshal.FreeHGlobal(_pixelBuffer);
|
||||
_pixelBuffer = Marshal.AllocHGlobal(needed);
|
||||
_bufferSize = needed;
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateUI()
|
||||
{
|
||||
_scrollView = new SkiaScrollView { BackgroundColor = new SKColor(250, 250, 250) };
|
||||
_rootLayout = new SkiaStackLayout {
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical,
|
||||
Spacing = 12, Padding = new SKRect(24, 24, 24, 24),
|
||||
BackgroundColor = new SKColor(250, 250, 250)
|
||||
};
|
||||
|
||||
// Title
|
||||
_rootLayout.AddChild(new SkiaLabel { Text = "MAUI Linux Demo", FontSize = 28, IsBold = true,
|
||||
TextColor = new SKColor(25, 118, 210), RequestedHeight = 40 });
|
||||
|
||||
// Basic Controls
|
||||
AddSection("Basic Controls");
|
||||
|
||||
var button = new SkiaButton { Text = "Click Me!", RequestedHeight = 44 };
|
||||
button.Clicked += (s, e) => Console.WriteLine("Button clicked!");
|
||||
_rootLayout.AddChild(button);
|
||||
|
||||
_rootLayout.AddChild(new SkiaLabel { Text = "This is a Label with some text", RequestedHeight = 24 });
|
||||
|
||||
_entry = new SkiaEntry { Placeholder = "Type here...", RequestedHeight = 44 };
|
||||
_rootLayout.AddChild(_entry);
|
||||
|
||||
// Toggle Controls
|
||||
AddSection("Toggle Controls");
|
||||
|
||||
var checkbox = new SkiaCheckBox { IsChecked = true, RequestedHeight = 32 };
|
||||
_rootLayout.AddChild(checkbox);
|
||||
|
||||
var switchCtrl = new SkiaSwitch { IsOn = true, RequestedHeight = 32 };
|
||||
_rootLayout.AddChild(switchCtrl);
|
||||
|
||||
// Sliders
|
||||
AddSection("Sliders & Progress");
|
||||
|
||||
var slider = new SkiaSlider { Value = 0.5, Minimum = 0, Maximum = 1, RequestedHeight = 40 };
|
||||
_rootLayout.AddChild(slider);
|
||||
|
||||
var progress = new SkiaProgressBar { Progress = 0.7f, RequestedHeight = 16 };
|
||||
_rootLayout.AddChild(progress);
|
||||
|
||||
// Pickers - These are the ones with popups
|
||||
AddSection("Pickers (click to open popups)");
|
||||
|
||||
_datePicker = new SkiaDatePicker { Date = DateTime.Today, RequestedHeight = 44 };
|
||||
_rootLayout.AddChild(_datePicker);
|
||||
|
||||
_timePicker = new SkiaTimePicker { Time = DateTime.Now.TimeOfDay, RequestedHeight = 44 };
|
||||
_rootLayout.AddChild(_timePicker);
|
||||
|
||||
_picker = new SkiaPicker { Title = "Select a fruit...", RequestedHeight = 44 };
|
||||
_picker.SetItems(new[] { "Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape" });
|
||||
_rootLayout.AddChild(_picker);
|
||||
|
||||
// CollectionView
|
||||
AddSection("CollectionView (scroll with mouse wheel)");
|
||||
|
||||
_collectionView = new SkiaCollectionView { RequestedHeight = 180, ItemHeight = 36 };
|
||||
var items = new List<string>();
|
||||
for (int i = 1; i <= 50; i++) items.Add($"Collection Item #{i}");
|
||||
_collectionView.ItemsSource = items;
|
||||
_rootLayout.AddChild(_collectionView);
|
||||
|
||||
// Activity Indicator
|
||||
AddSection("Activity Indicator");
|
||||
var activity = new SkiaActivityIndicator { IsRunning = true, RequestedHeight = 50 };
|
||||
_rootLayout.AddChild(activity);
|
||||
|
||||
// SearchBar
|
||||
AddSection("SearchBar");
|
||||
_searchBar = new SkiaSearchBar { Placeholder = "Search...", RequestedHeight = 44 };
|
||||
_rootLayout.AddChild(_searchBar);
|
||||
|
||||
// Footer
|
||||
_rootLayout.AddChild(new SkiaLabel {
|
||||
Text = "Scroll this page to see all controls. ESC to exit.",
|
||||
FontSize = 12, TextColor = new SKColor(128, 128, 128), RequestedHeight = 30
|
||||
});
|
||||
|
||||
_scrollView.Content = _rootLayout;
|
||||
}
|
||||
|
||||
private void AddSection(string title)
|
||||
{
|
||||
_rootLayout.AddChild(new SkiaLabel {
|
||||
Text = title, FontSize = 16, IsBold = true,
|
||||
TextColor = new SKColor(55, 71, 79), RequestedHeight = 32
|
||||
});
|
||||
}
|
||||
|
||||
private void RunEventLoop()
|
||||
{
|
||||
Console.WriteLine("MAUI Linux Demo running... ESC to quit");
|
||||
Console.WriteLine("- Click DatePicker/TimePicker/Picker to test popups");
|
||||
Console.WriteLine("- Use mouse wheel on CollectionView to scroll it");
|
||||
Console.WriteLine("- Use mouse wheel elsewhere to scroll the page");
|
||||
Render();
|
||||
var lastRender = DateTime.Now;
|
||||
while (_running) {
|
||||
while (XPending(_display) > 0) { XNextEvent(_display, out var ev); HandleEvent(ref ev); }
|
||||
|
||||
// Continuous rendering for animations (ActivityIndicator, cursor blink, etc.)
|
||||
var now = DateTime.Now;
|
||||
if ((now - lastRender).TotalMilliseconds >= 50) // ~20 FPS for animations
|
||||
{
|
||||
lastRender = now;
|
||||
Render();
|
||||
}
|
||||
Thread.Sleep(8);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEvent(ref XEvent e)
|
||||
{
|
||||
switch (e.type)
|
||||
{
|
||||
case Expose: if (e.xexpose.count == 0) Render(); break;
|
||||
case ConfigureNotify:
|
||||
if (e.xconfigure.width != _width || e.xconfigure.height != _height) {
|
||||
_width = e.xconfigure.width; _height = e.xconfigure.height;
|
||||
EnsurePixelBuffer(_width, _height); Render();
|
||||
}
|
||||
break;
|
||||
case KeyPress:
|
||||
var keysym = XLookupKeysym(ref e.xkey, 0);
|
||||
if (keysym == 0xFF1B) { _running = false; break; } // ESC
|
||||
|
||||
// Forward to focused view
|
||||
if (_focusedView != null)
|
||||
{
|
||||
var key = KeysymToKey(keysym);
|
||||
if (key != Key.Unknown)
|
||||
{
|
||||
_focusedView.OnKeyDown(new KeyEventArgs(key));
|
||||
Render();
|
||||
}
|
||||
|
||||
// Handle text input for printable characters
|
||||
var ch = KeysymToChar(keysym, e.xkey.state);
|
||||
if (ch != '\0')
|
||||
{
|
||||
_focusedView.OnTextInput(new TextInputEventArgs(ch.ToString()));
|
||||
Render();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ButtonPress:
|
||||
float sx = e.xbutton.x, sy = e.xbutton.y;
|
||||
if (e.xbutton.button == 4 || e.xbutton.button == 5) {
|
||||
// Mouse wheel
|
||||
var cvBounds = _collectionView.GetAbsoluteBounds();
|
||||
bool overCV = sx >= cvBounds.Left && sx <= cvBounds.Right &&
|
||||
sy >= cvBounds.Top && sy <= cvBounds.Bottom;
|
||||
float delta = (e.xbutton.button == 4) ? -1.5f : 1.5f;
|
||||
if (overCV) {
|
||||
_collectionView.OnScroll(new ScrollEventArgs(sx, sy, 0, delta));
|
||||
} else {
|
||||
_scrollView.ScrollY = Math.Max(0, _scrollView.ScrollY + (delta > 0 ? 40 : -40));
|
||||
}
|
||||
Render();
|
||||
} else {
|
||||
// Check if clicking on popup areas first
|
||||
bool handledPopup = HandlePopupClick(sx, sy);
|
||||
if (!handledPopup) {
|
||||
_pressedView = _scrollView.HitTest(sx, sy);
|
||||
if (_pressedView != null && _pressedView != _scrollView) {
|
||||
// Update focus
|
||||
if (_pressedView != _focusedView && _pressedView.IsFocusable)
|
||||
{
|
||||
_focusedView?.OnFocusLost();
|
||||
_focusedView = _pressedView;
|
||||
_focusedView.OnFocusGained();
|
||||
}
|
||||
_pressedView.OnPointerPressed(new Microsoft.Maui.Platform.PointerEventArgs(sx, sy, Microsoft.Maui.Platform.PointerButton.Left));
|
||||
}
|
||||
else if (_pressedView == null || _pressedView == _scrollView)
|
||||
{
|
||||
// Clicked on empty area - clear focus
|
||||
_focusedView?.OnFocusLost();
|
||||
_focusedView = null;
|
||||
}
|
||||
}
|
||||
Render();
|
||||
}
|
||||
break;
|
||||
case MotionNotify:
|
||||
// Forward drag events to pressed view (for sliders, etc.)
|
||||
if (_pressedView != null) {
|
||||
// Close any open popups during drag to prevent glitches
|
||||
if (_datePicker.IsOpen) _datePicker.IsOpen = false;
|
||||
if (_timePicker.IsOpen) _timePicker.IsOpen = false;
|
||||
if (_picker.IsOpen) _picker.IsOpen = false;
|
||||
|
||||
_pressedView.OnPointerMoved(new Microsoft.Maui.Platform.PointerEventArgs(e.xmotion.x, e.xmotion.y, Microsoft.Maui.Platform.PointerButton.Left));
|
||||
|
||||
// Throttle motion renders to prevent overwhelming the system
|
||||
var now = DateTime.Now;
|
||||
if ((now - _lastMotionRender).TotalMilliseconds >= 16) // ~60 FPS max for drag
|
||||
{
|
||||
_lastMotionRender = now;
|
||||
Render();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ButtonRelease:
|
||||
if (e.xbutton.button != 4 && e.xbutton.button != 5 && _pressedView != null) {
|
||||
_pressedView.OnPointerReleased(new Microsoft.Maui.Platform.PointerEventArgs(e.xbutton.x, e.xbutton.y, Microsoft.Maui.Platform.PointerButton.Left));
|
||||
_pressedView = null;
|
||||
Render();
|
||||
}
|
||||
break;
|
||||
case ClientMessage:
|
||||
if (e.xclient.data_l0 == (long)_wmDeleteMessage) _running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HandlePopupClick(float x, float y)
|
||||
{
|
||||
// Handle date picker popup clicks
|
||||
if (_datePicker.IsOpen)
|
||||
{
|
||||
var bounds = _datePicker.GetAbsoluteBounds();
|
||||
var popupRect = new SKRect(bounds.Left, bounds.Bottom + 4, bounds.Left + 280, bounds.Bottom + 324);
|
||||
if (x >= popupRect.Left && x <= popupRect.Right && y >= popupRect.Top && y <= popupRect.Bottom)
|
||||
{
|
||||
// Click inside popup - handle calendar navigation/selection
|
||||
HandleDatePickerPopupClick(x, y, bounds);
|
||||
return true;
|
||||
}
|
||||
else if (y >= bounds.Top && y <= bounds.Bottom && x >= bounds.Left && x <= bounds.Right)
|
||||
{
|
||||
// Click on picker button - toggle
|
||||
_datePicker.IsOpen = false;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Click outside - close
|
||||
_datePicker.IsOpen = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle time picker popup clicks
|
||||
if (_timePicker.IsOpen)
|
||||
{
|
||||
var bounds = _timePicker.GetAbsoluteBounds();
|
||||
var popupRect = new SKRect(bounds.Left, bounds.Bottom + 4, bounds.Left + 280, bounds.Bottom + 364);
|
||||
if (y < popupRect.Top)
|
||||
{
|
||||
_timePicker.IsOpen = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dropdown picker popup clicks
|
||||
if (_picker.IsOpen)
|
||||
{
|
||||
var bounds = _picker.GetAbsoluteBounds();
|
||||
var dropdownRect = new SKRect(bounds.Left, bounds.Bottom + 4, bounds.Right, bounds.Bottom + 204);
|
||||
if (x >= dropdownRect.Left && x <= dropdownRect.Right && y >= dropdownRect.Top && y <= dropdownRect.Bottom)
|
||||
{
|
||||
// Click on item
|
||||
int itemIndex = (int)((y - dropdownRect.Top) / 40);
|
||||
if (itemIndex >= 0 && itemIndex < 7)
|
||||
{
|
||||
_picker.SelectedIndex = itemIndex;
|
||||
}
|
||||
_picker.IsOpen = false;
|
||||
return true;
|
||||
}
|
||||
else if (y < dropdownRect.Top)
|
||||
{
|
||||
_picker.IsOpen = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private DateTime _displayMonth = DateTime.Today;
|
||||
|
||||
private void HandleDatePickerPopupClick(float x, float y, SKRect pickerBounds)
|
||||
{
|
||||
var popupTop = pickerBounds.Bottom + 4;
|
||||
var headerHeight = 48f;
|
||||
var weekdayHeight = 30f;
|
||||
|
||||
// Navigation arrows
|
||||
if (y >= popupTop && y < popupTop + headerHeight)
|
||||
{
|
||||
if (x < pickerBounds.Left + 40)
|
||||
{
|
||||
_displayMonth = _displayMonth.AddMonths(-1);
|
||||
}
|
||||
else if (x > pickerBounds.Left + 240)
|
||||
{
|
||||
_displayMonth = _displayMonth.AddMonths(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Day selection
|
||||
var daysTop = popupTop + headerHeight + weekdayHeight;
|
||||
if (y >= daysTop)
|
||||
{
|
||||
var cellWidth = 280f / 7;
|
||||
var cellHeight = 38f;
|
||||
var col = (int)((x - pickerBounds.Left) / cellWidth);
|
||||
var row = (int)((y - daysTop) / cellHeight);
|
||||
|
||||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
var dayIndex = row * 7 + col - startDayOfWeek + 1;
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
|
||||
if (dayIndex >= 1 && dayIndex <= daysInMonth)
|
||||
{
|
||||
_datePicker.Date = new DateTime(_displayMonth.Year, _displayMonth.Month, dayIndex);
|
||||
_datePicker.IsOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
_scrollView.Measure(new SKSize(_width, _height));
|
||||
_scrollView.Arrange(new SKRect(0, 0, _width, _height));
|
||||
|
||||
var info = new SKImageInfo(_width, _height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
||||
using var surface = SKSurface.Create(info, _pixelBuffer, _width * 4);
|
||||
if (surface == null) return;
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
canvas.Clear(new SKColor(250, 250, 250));
|
||||
_scrollView.Draw(canvas);
|
||||
|
||||
// Draw popups on top (outside of scrollview clipping)
|
||||
DrawPopups(canvas);
|
||||
|
||||
canvas.Flush();
|
||||
|
||||
var image = XCreateImage(_display, XDefaultVisual(_display, _screen),
|
||||
(uint)XDefaultDepth(_display, _screen), 2, 0, _pixelBuffer, (uint)_width, (uint)_height, 32, _width * 4);
|
||||
if (image != IntPtr.Zero) {
|
||||
XPutImage(_display, _window, _gc, image, 0, 0, 0, 0, (uint)_width, (uint)_height);
|
||||
XFree(image);
|
||||
}
|
||||
XFlush(_display);
|
||||
}
|
||||
|
||||
private void DrawPopups(SKCanvas canvas)
|
||||
{
|
||||
// Draw DatePicker calendar popup
|
||||
if (_datePicker.IsOpen)
|
||||
{
|
||||
var bounds = _datePicker.GetAbsoluteBounds();
|
||||
DrawCalendarPopup(canvas, bounds);
|
||||
}
|
||||
|
||||
// Draw TimePicker clock popup
|
||||
if (_timePicker.IsOpen)
|
||||
{
|
||||
var bounds = _timePicker.GetAbsoluteBounds();
|
||||
DrawTimePickerPopup(canvas, bounds);
|
||||
}
|
||||
|
||||
// Draw Picker dropdown
|
||||
if (_picker.IsOpen)
|
||||
{
|
||||
var bounds = _picker.GetAbsoluteBounds();
|
||||
DrawPickerDropdown(canvas, bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCalendarPopup(SKCanvas canvas, SKRect pickerBounds)
|
||||
{
|
||||
var popupRect = new SKRect(
|
||||
pickerBounds.Left, pickerBounds.Bottom + 4,
|
||||
pickerBounds.Left + 280, pickerBounds.Bottom + 324);
|
||||
|
||||
// Shadow
|
||||
using var shadowPaint = new SKPaint {
|
||||
Color = new SKColor(0, 0, 0, 50),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6)
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(
|
||||
new SKRect(popupRect.Left + 3, popupRect.Top + 3, popupRect.Right + 3, popupRect.Bottom + 3), 8), shadowPaint);
|
||||
|
||||
// Background
|
||||
using var bgPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, 8), bgPaint);
|
||||
|
||||
// Border
|
||||
using var borderPaint = new SKPaint { Color = new SKColor(200, 200, 200), Style = SKPaintStyle.Stroke, StrokeWidth = 1 };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, 8), borderPaint);
|
||||
|
||||
// Header with month/year
|
||||
var headerRect = new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + 48);
|
||||
using var headerPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(headerRect.Left, headerRect.Top, headerRect.Right, headerRect.Top + 16), 8));
|
||||
canvas.DrawRect(headerRect, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(headerRect.Left, headerRect.Top + 8, headerRect.Right, headerRect.Bottom), headerPaint);
|
||||
|
||||
// Month/year text
|
||||
using var headerFont = new SKFont(SKTypeface.Default, 18);
|
||||
using var headerTextPaint = new SKPaint(headerFont) { Color = SKColors.White, IsAntialias = true };
|
||||
var monthYear = _displayMonth.ToString("MMMM yyyy");
|
||||
var textBounds = new SKRect();
|
||||
headerTextPaint.MeasureText(monthYear, ref textBounds);
|
||||
canvas.DrawText(monthYear, headerRect.MidX - textBounds.MidX, headerRect.MidY - textBounds.MidY, headerTextPaint);
|
||||
|
||||
// Navigation arrows
|
||||
using var arrowPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true };
|
||||
// Left arrow
|
||||
canvas.DrawLine(popupRect.Left + 24, headerRect.MidY, popupRect.Left + 18, headerRect.MidY, arrowPaint);
|
||||
canvas.DrawLine(popupRect.Left + 18, headerRect.MidY, popupRect.Left + 22, headerRect.MidY - 4, arrowPaint);
|
||||
canvas.DrawLine(popupRect.Left + 18, headerRect.MidY, popupRect.Left + 22, headerRect.MidY + 4, arrowPaint);
|
||||
// Right arrow
|
||||
canvas.DrawLine(popupRect.Right - 24, headerRect.MidY, popupRect.Right - 18, headerRect.MidY, arrowPaint);
|
||||
canvas.DrawLine(popupRect.Right - 18, headerRect.MidY, popupRect.Right - 22, headerRect.MidY - 4, arrowPaint);
|
||||
canvas.DrawLine(popupRect.Right - 18, headerRect.MidY, popupRect.Right - 22, headerRect.MidY + 4, arrowPaint);
|
||||
|
||||
// Weekday headers
|
||||
var dayNames = new[] { "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" };
|
||||
var cellWidth = 280f / 7;
|
||||
var weekdayTop = popupRect.Top + 48;
|
||||
using var weekdayFont = new SKFont(SKTypeface.Default, 12);
|
||||
using var weekdayPaint = new SKPaint(weekdayFont) { Color = new SKColor(128, 128, 128), IsAntialias = true };
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var dayBounds = new SKRect();
|
||||
weekdayPaint.MeasureText(dayNames[i], ref dayBounds);
|
||||
var x = popupRect.Left + i * cellWidth + cellWidth / 2 - dayBounds.MidX;
|
||||
canvas.DrawText(dayNames[i], x, weekdayTop + 20, weekdayPaint);
|
||||
}
|
||||
|
||||
// Days grid
|
||||
var daysTop = weekdayTop + 30;
|
||||
var cellHeight = 38f;
|
||||
var firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
|
||||
var daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
|
||||
var startDayOfWeek = (int)firstDay.DayOfWeek;
|
||||
var today = DateTime.Today;
|
||||
var selectedDate = _datePicker.Date;
|
||||
|
||||
using var dayFont = new SKFont(SKTypeface.Default, 14);
|
||||
using var dayPaint = new SKPaint(dayFont) { IsAntialias = true };
|
||||
using var circlePaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
|
||||
for (int day = 1; day <= daysInMonth; day++)
|
||||
{
|
||||
var dayDate = new DateTime(_displayMonth.Year, _displayMonth.Month, day);
|
||||
var cellIndex = startDayOfWeek + day - 1;
|
||||
var row = cellIndex / 7;
|
||||
var col = cellIndex % 7;
|
||||
|
||||
var cellX = popupRect.Left + col * cellWidth;
|
||||
var cellY = daysTop + row * cellHeight;
|
||||
var cellCenterX = cellX + cellWidth / 2;
|
||||
var cellCenterY = cellY + cellHeight / 2;
|
||||
|
||||
var isSelected = dayDate.Date == selectedDate.Date;
|
||||
var isToday = dayDate.Date == today;
|
||||
|
||||
// Draw selection/today circle
|
||||
if (isSelected)
|
||||
{
|
||||
circlePaint.Color = new SKColor(33, 150, 243);
|
||||
canvas.DrawCircle(cellCenterX, cellCenterY, 16, circlePaint);
|
||||
}
|
||||
else if (isToday)
|
||||
{
|
||||
circlePaint.Color = new SKColor(33, 150, 243, 60);
|
||||
canvas.DrawCircle(cellCenterX, cellCenterY, 16, circlePaint);
|
||||
}
|
||||
|
||||
// Draw day number
|
||||
dayPaint.Color = isSelected ? SKColors.White : SKColors.Black;
|
||||
var dayText = day.ToString();
|
||||
var dayBounds = new SKRect();
|
||||
dayPaint.MeasureText(dayText, ref dayBounds);
|
||||
canvas.DrawText(dayText, cellCenterX - dayBounds.MidX, cellCenterY - dayBounds.MidY, dayPaint);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTimePickerPopup(SKCanvas canvas, SKRect pickerBounds)
|
||||
{
|
||||
var popupRect = new SKRect(
|
||||
pickerBounds.Left, pickerBounds.Bottom + 4,
|
||||
pickerBounds.Left + 280, pickerBounds.Bottom + 364);
|
||||
|
||||
// Shadow
|
||||
using var shadowPaint = new SKPaint {
|
||||
Color = new SKColor(0, 0, 0, 50),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6)
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(
|
||||
new SKRect(popupRect.Left + 3, popupRect.Top + 3, popupRect.Right + 3, popupRect.Bottom + 3), 8), shadowPaint);
|
||||
|
||||
// Background
|
||||
using var bgPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(popupRect, 8), bgPaint);
|
||||
|
||||
// Header
|
||||
var headerRect = new SKRect(popupRect.Left, popupRect.Top, popupRect.Right, popupRect.Top + 80);
|
||||
using var headerPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Fill };
|
||||
canvas.Save();
|
||||
canvas.ClipRoundRect(new SKRoundRect(new SKRect(headerRect.Left, headerRect.Top, headerRect.Right, headerRect.Top + 16), 8));
|
||||
canvas.DrawRect(headerRect, headerPaint);
|
||||
canvas.Restore();
|
||||
canvas.DrawRect(new SKRect(headerRect.Left, headerRect.Top + 8, headerRect.Right, headerRect.Bottom), headerPaint);
|
||||
|
||||
// Time display
|
||||
using var timeFont = new SKFont(SKTypeface.Default, 32);
|
||||
using var timePaint = new SKPaint(timeFont) { Color = SKColors.White, IsAntialias = true };
|
||||
var time = _timePicker.Time;
|
||||
var timeText = $"{time.Hours:D2}:{time.Minutes:D2}";
|
||||
var timeBounds = new SKRect();
|
||||
timePaint.MeasureText(timeText, ref timeBounds);
|
||||
canvas.DrawText(timeText, headerRect.MidX - timeBounds.MidX, headerRect.MidY - timeBounds.MidY, timePaint);
|
||||
|
||||
// Clock face
|
||||
var clockCenterX = popupRect.MidX;
|
||||
var clockCenterY = popupRect.Top + 80 + 140;
|
||||
var clockRadius = 100f;
|
||||
|
||||
using var clockBgPaint = new SKPaint { Color = new SKColor(245, 245, 245), Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawCircle(clockCenterX, clockCenterY, clockRadius + 20, clockBgPaint);
|
||||
|
||||
// Hour numbers
|
||||
using var numFont = new SKFont(SKTypeface.Default, 14);
|
||||
using var numPaint = new SKPaint(numFont) { Color = SKColors.Black, IsAntialias = true };
|
||||
for (int i = 1; i <= 12; i++)
|
||||
{
|
||||
var angle = (i * 30 - 90) * Math.PI / 180;
|
||||
var x = clockCenterX + (float)(clockRadius * Math.Cos(angle));
|
||||
var y = clockCenterY + (float)(clockRadius * Math.Sin(angle));
|
||||
var numText = i.ToString();
|
||||
var numBounds = new SKRect();
|
||||
numPaint.MeasureText(numText, ref numBounds);
|
||||
canvas.DrawText(numText, x - numBounds.MidX, y - numBounds.MidY, numPaint);
|
||||
}
|
||||
|
||||
// Clock hand
|
||||
var selectedHour = time.Hours % 12;
|
||||
if (selectedHour == 0) selectedHour = 12;
|
||||
var handAngle = (selectedHour * 30 - 90) * Math.PI / 180;
|
||||
var handEndX = clockCenterX + (float)((clockRadius - 20) * Math.Cos(handAngle));
|
||||
var handEndY = clockCenterY + (float)((clockRadius - 20) * Math.Sin(handAngle));
|
||||
|
||||
using var handPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true };
|
||||
canvas.DrawLine(clockCenterX, clockCenterY, handEndX, handEndY, handPaint);
|
||||
|
||||
// Center dot
|
||||
handPaint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawCircle(clockCenterX, clockCenterY, 6, handPaint);
|
||||
|
||||
// Selected hour highlight
|
||||
using var selPaint = new SKPaint { Color = new SKColor(33, 150, 243), Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
var selX = clockCenterX + (float)(clockRadius * Math.Cos(handAngle));
|
||||
var selY = clockCenterY + (float)(clockRadius * Math.Sin(handAngle));
|
||||
canvas.DrawCircle(selX, selY, 18, selPaint);
|
||||
numPaint.Color = SKColors.White;
|
||||
var selText = selectedHour.ToString();
|
||||
var selBounds = new SKRect();
|
||||
numPaint.MeasureText(selText, ref selBounds);
|
||||
canvas.DrawText(selText, selX - selBounds.MidX, selY - selBounds.MidY, numPaint);
|
||||
}
|
||||
|
||||
private void DrawPickerDropdown(SKCanvas canvas, SKRect pickerBounds)
|
||||
{
|
||||
var items = new[] { "Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape" };
|
||||
var itemHeight = 40f;
|
||||
var dropdownHeight = items.Length * itemHeight;
|
||||
|
||||
var dropdownRect = new SKRect(
|
||||
pickerBounds.Left, pickerBounds.Bottom + 4,
|
||||
pickerBounds.Right, pickerBounds.Bottom + 4 + dropdownHeight);
|
||||
|
||||
// Shadow
|
||||
using var shadowPaint = new SKPaint {
|
||||
Color = new SKColor(0, 0, 0, 50),
|
||||
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 6)
|
||||
};
|
||||
canvas.DrawRoundRect(new SKRoundRect(
|
||||
new SKRect(dropdownRect.Left + 3, dropdownRect.Top + 3, dropdownRect.Right + 3, dropdownRect.Bottom + 3), 4), shadowPaint);
|
||||
|
||||
// Background
|
||||
using var bgPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill, IsAntialias = true };
|
||||
canvas.DrawRoundRect(new SKRoundRect(dropdownRect, 4), bgPaint);
|
||||
|
||||
// Border
|
||||
using var borderPaint = new SKPaint { Color = new SKColor(200, 200, 200), Style = SKPaintStyle.Stroke, StrokeWidth = 1 };
|
||||
canvas.DrawRoundRect(new SKRoundRect(dropdownRect, 4), borderPaint);
|
||||
|
||||
// Items
|
||||
using var itemFont = new SKFont(SKTypeface.Default, 14);
|
||||
using var itemPaint = new SKPaint(itemFont) { Color = SKColors.Black, IsAntialias = true };
|
||||
using var selBgPaint = new SKPaint { Color = new SKColor(33, 150, 243, 40), Style = SKPaintStyle.Fill };
|
||||
|
||||
for (int i = 0; i < items.Length; i++)
|
||||
{
|
||||
var itemTop = dropdownRect.Top + i * itemHeight;
|
||||
var itemRect = new SKRect(dropdownRect.Left, itemTop, dropdownRect.Right, itemTop + itemHeight);
|
||||
|
||||
if (i == _picker.SelectedIndex)
|
||||
{
|
||||
canvas.DrawRect(itemRect, selBgPaint);
|
||||
}
|
||||
|
||||
var textBounds = new SKRect();
|
||||
itemPaint.MeasureText(items[i], ref textBounds);
|
||||
canvas.DrawText(items[i], itemRect.Left + 12, itemRect.MidY - textBounds.MidY, itemPaint);
|
||||
}
|
||||
}
|
||||
|
||||
private Key KeysymToKey(ulong keysym)
|
||||
{
|
||||
return keysym switch
|
||||
{
|
||||
0xFF08 => Key.Backspace,
|
||||
0xFF09 => Key.Tab,
|
||||
0xFF0D => Key.Enter,
|
||||
0xFF1B => Key.Escape,
|
||||
0xFFFF => Key.Delete,
|
||||
0xFF50 => Key.Home,
|
||||
0xFF51 => Key.Left,
|
||||
0xFF52 => Key.Up,
|
||||
0xFF53 => Key.Right,
|
||||
0xFF54 => Key.Down,
|
||||
0xFF55 => Key.PageUp,
|
||||
0xFF56 => Key.PageDown,
|
||||
0xFF57 => Key.End,
|
||||
0x0020 => Key.Space,
|
||||
_ => Key.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private char KeysymToChar(ulong keysym, uint state)
|
||||
{
|
||||
bool shift = (state & 1) != 0; // ShiftMask
|
||||
bool capsLock = (state & 2) != 0; // LockMask
|
||||
|
||||
// Letters a-z / A-Z
|
||||
if (keysym >= 0x61 && keysym <= 0x7A) // a-z
|
||||
{
|
||||
char ch = (char)keysym;
|
||||
if (shift ^ capsLock) ch = char.ToUpper(ch);
|
||||
return ch;
|
||||
}
|
||||
|
||||
// Numbers and symbols
|
||||
if (keysym >= 0x20 && keysym <= 0x7E)
|
||||
{
|
||||
if (shift)
|
||||
{
|
||||
return keysym switch
|
||||
{
|
||||
0x31 => '!', 0x32 => '@', 0x33 => '#', 0x34 => '$', 0x35 => '%',
|
||||
0x36 => '^', 0x37 => '&', 0x38 => '*', 0x39 => '(', 0x30 => ')',
|
||||
0x2D => '_', 0x3D => '+', 0x5B => '{', 0x5D => '}', 0x5C => '|',
|
||||
0x3B => ':', 0x27 => '"', 0x60 => '~', 0x2C => '<', 0x2E => '>',
|
||||
0x2F => '?',
|
||||
_ => (char)keysym
|
||||
};
|
||||
}
|
||||
return (char)keysym;
|
||||
}
|
||||
|
||||
// Numpad
|
||||
if (keysym >= 0xFFB0 && keysym <= 0xFFB9)
|
||||
return (char)('0' + (keysym - 0xFFB0));
|
||||
|
||||
return '\0';
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
if (_pixelBuffer != IntPtr.Zero) Marshal.FreeHGlobal(_pixelBuffer);
|
||||
if (_gc != IntPtr.Zero) XFreeGC(_display, _gc);
|
||||
if (_window != IntPtr.Zero) XDestroyWindow(_display, _window);
|
||||
if (_display != IntPtr.Zero) XCloseDisplay(_display);
|
||||
}
|
||||
|
||||
const string LibX11 = "libX11.so.6";
|
||||
[DllImport(LibX11)] static extern IntPtr XOpenDisplay(IntPtr d);
|
||||
[DllImport(LibX11)] static extern int XCloseDisplay(IntPtr d);
|
||||
[DllImport(LibX11)] static extern int XDefaultScreen(IntPtr d);
|
||||
[DllImport(LibX11)] static extern IntPtr XRootWindow(IntPtr d, int s);
|
||||
[DllImport(LibX11)] static extern ulong XBlackPixel(IntPtr d, int s);
|
||||
[DllImport(LibX11)] static extern ulong XWhitePixel(IntPtr d, int s);
|
||||
[DllImport(LibX11)] static extern IntPtr XCreateSimpleWindow(IntPtr d, IntPtr p, int x, int y, uint w, uint h, uint bw, ulong b, ulong bg);
|
||||
[DllImport(LibX11)] static extern int XMapWindow(IntPtr d, IntPtr w);
|
||||
[DllImport(LibX11)] static extern int XStoreName(IntPtr d, IntPtr w, string n);
|
||||
[DllImport(LibX11)] static extern int XSelectInput(IntPtr d, IntPtr w, long m);
|
||||
[DllImport(LibX11)] static extern IntPtr XCreateGC(IntPtr d, IntPtr dr, ulong vm, IntPtr v);
|
||||
[DllImport(LibX11)] static extern int XFreeGC(IntPtr d, IntPtr gc);
|
||||
[DllImport(LibX11)] static extern int XFlush(IntPtr d);
|
||||
[DllImport(LibX11)] static extern int XPending(IntPtr d);
|
||||
[DllImport(LibX11)] static extern int XNextEvent(IntPtr d, out XEvent e);
|
||||
[DllImport(LibX11)] static extern ulong XLookupKeysym(ref XKeyEvent k, int i);
|
||||
[DllImport(LibX11)] static extern int XDestroyWindow(IntPtr d, IntPtr w);
|
||||
[DllImport(LibX11)] static extern IntPtr XDefaultVisual(IntPtr d, int s);
|
||||
[DllImport(LibX11)] static extern int XDefaultDepth(IntPtr d, int s);
|
||||
[DllImport(LibX11)] static extern IntPtr XCreateImage(IntPtr d, IntPtr v, uint dp, int f, int o, IntPtr data, uint w, uint h, int bp, int bpl);
|
||||
[DllImport(LibX11)] static extern int XPutImage(IntPtr d, IntPtr dr, IntPtr gc, IntPtr i, int sx, int sy, int dx, int dy, uint w, uint h);
|
||||
[DllImport(LibX11)] static extern int XFree(IntPtr data);
|
||||
[DllImport(LibX11)] static extern IntPtr XInternAtom(IntPtr d, string n, bool o);
|
||||
[DllImport(LibX11)] static extern int XSetWMProtocols(IntPtr d, IntPtr w, ref IntPtr p, int c);
|
||||
|
||||
const long ExposureMask = 1L<<15, KeyPressMask = 1L<<0, KeyReleaseMask = 1L<<1;
|
||||
const long ButtonPressMask = 1L<<2, ButtonReleaseMask = 1L<<3, PointerMotionMask = 1L<<6, StructureNotifyMask = 1L<<17;
|
||||
const int KeyPress = 2, ButtonPress = 4, ButtonRelease = 5, MotionNotify = 6, Expose = 12, ConfigureNotify = 22, ClientMessage = 33;
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 192)] struct XEvent {
|
||||
[FieldOffset(0)] public int type; [FieldOffset(0)] public XExposeEvent xexpose;
|
||||
[FieldOffset(0)] public XConfigureEvent xconfigure; [FieldOffset(0)] public XKeyEvent xkey;
|
||||
[FieldOffset(0)] public XButtonEvent xbutton; [FieldOffset(0)] public XMotionEvent xmotion;
|
||||
[FieldOffset(0)] public XClientMessageEvent xclient;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] struct XExposeEvent { public int type; public ulong serial; public int send_event; public IntPtr display, window; public int x, y, width, height, count; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct XConfigureEvent { public int type; public ulong serial; public int send_event; public IntPtr display, evt, window; public int x, y, width, height, border_width; public IntPtr above; public int override_redirect; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct XKeyEvent { public int type; public ulong serial; public int send_event; public IntPtr display, window, root, subwindow; public ulong time; public int x, y, x_root, y_root; public uint state, keycode; public int same_screen; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct XButtonEvent { public int type; public ulong serial; public int send_event; public IntPtr display, window, root, subwindow; public ulong time; public int x, y, x_root, y_root; public uint state, button; public int same_screen; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct XMotionEvent { public int type; public ulong serial; public int send_event; public IntPtr display, window, root, subwindow; public ulong time; public int x, y, x_root, y_root; public uint state; public byte is_hint; public int same_screen; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct XClientMessageEvent { public int type; public ulong serial; public int send_event; public IntPtr display, window, message_type; public int format; public long data_l0, data_l1, data_l2, data_l3, data_l4; }
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageType>Template</PackageType>
|
||||
<PackageVersion>1.0.0-preview.4</PackageVersion>
|
||||
<PackageId>Microsoft.Maui.Linux.Templates</PackageId>
|
||||
<Title>.NET MAUI Linux Project Templates</Title>
|
||||
<Authors>MAUI Linux Community Contributors</Authors>
|
||||
<Description>Project templates for building .NET MAUI applications on Linux desktop.</Description>
|
||||
<PackageTags>dotnet-new;templates;maui;linux;desktop</PackageTags>
|
||||
<PackageProjectUrl>https://github.com/dotnet/maui</PackageProjectUrl>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
||||
<IncludeContentInPack>true</IncludeContentInPack>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<ContentTargetFolders>content</ContentTargetFolders>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="maui-linux-app\**\*" Exclude="maui-linux-app\**\bin\**;maui-linux-app\**\obj\**" />
|
||||
<Compile Remove="**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<PackageType>Template</PackageType>
|
||||
<PackageVersion>1.0.0-preview.1</PackageVersion>
|
||||
<PackageId>OpenMaui.Linux.Templates</PackageId>
|
||||
<Title>OpenMaui Linux Project Templates</Title>
|
||||
<Authors>MarketAlly LLC, David H. Friedel Jr.</Authors>
|
||||
<Company>MarketAlly LLC</Company>
|
||||
<Description>Project templates for building .NET MAUI applications on Linux desktop using OpenMaui.
|
||||
|
||||
Templates included:
|
||||
- openmaui-linux: Basic Linux app with code-based UI
|
||||
- openmaui-linux-xaml: Full XAML support with standard MAUI syntax</Description>
|
||||
<PackageTags>dotnet-new;templates;maui;linux;desktop;openmaui;xaml</PackageTags>
|
||||
<PackageProjectUrl>https://github.com/open-maui/maui-linux</PackageProjectUrl>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Copyright>Copyright 2025 MarketAlly LLC</Copyright>
|
||||
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
|
||||
<IncludeContentInPack>true</IncludeContentInPack>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<ContentTargetFolders>content</ContentTargetFolders>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Code-based template -->
|
||||
<Content Include="openmaui-linux-app\**\*" Exclude="openmaui-linux-app\**\bin\**;openmaui-linux-app\**\obj\**" />
|
||||
<!-- XAML-based template -->
|
||||
<Content Include="openmaui-linux-xaml-app\**\*" Exclude="openmaui-linux-xaml-app\**\bin\**;openmaui-linux-xaml-app\**\obj\**" />
|
||||
<Compile Remove="**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/template",
|
||||
"author": "MAUI Linux Community",
|
||||
"classifications": ["MAUI", "Linux", "Desktop", "App"],
|
||||
"identity": "Microsoft.Maui.Linux.App",
|
||||
"name": ".NET MAUI Linux Application",
|
||||
"shortName": "maui-linux",
|
||||
"author": "MarketAlly LLC",
|
||||
"classifications": ["MAUI", "Linux", "Desktop", "App", "OpenMaui"],
|
||||
"identity": "OpenMaui.Linux.App",
|
||||
"name": "OpenMaui Linux Application",
|
||||
"shortName": "openmaui-linux",
|
||||
"tags": {
|
||||
"language": "C#",
|
||||
"type": "project"
|
||||
},
|
||||
"sourceName": "MauiLinuxApp",
|
||||
"sourceName": "OpenMauiLinuxApp",
|
||||
"preferNameDirectory": true,
|
||||
"symbols": {
|
||||
"Framework": {
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
}
|
||||
},
|
||||
"primaryOutputs": [
|
||||
{ "path": "MauiLinuxApp.csproj" }
|
||||
{ "path": "OpenMauiLinuxApp.csproj" }
|
||||
],
|
||||
"postActions": [
|
||||
{
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
using Microsoft.Maui.Controls;
|
||||
|
||||
namespace MauiLinuxApp;
|
||||
namespace OpenMauiLinuxApp;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.Graphics;
|
||||
|
||||
namespace MauiLinuxApp;
|
||||
namespace OpenMauiLinuxApp;
|
||||
|
||||
public class MainPage : ContentPage
|
||||
{
|
||||
|
|
@ -13,7 +13,7 @@ public class MainPage : ContentPage
|
|||
|
||||
public MainPage()
|
||||
{
|
||||
Title = "MauiLinuxApp";
|
||||
Title = "OpenMauiLinuxApp";
|
||||
|
||||
_counterLabel = new Label
|
||||
{
|
||||
|
|
@ -5,14 +5,14 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>MauiLinuxApp</RootNamespace>
|
||||
<AssemblyName>MauiLinuxApp</AssemblyName>
|
||||
<ApplicationTitle>MauiLinuxApp</ApplicationTitle>
|
||||
<RootNamespace>OpenMauiLinuxApp</RootNamespace>
|
||||
<AssemblyName>OpenMauiLinuxApp</AssemblyName>
|
||||
<ApplicationTitle>OpenMauiLinuxApp</ApplicationTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- .NET MAUI Linux Platform -->
|
||||
<PackageReference Include="Microsoft.Maui.Controls.Linux" Version="1.0.0-preview.4" />
|
||||
<!-- OpenMaui Linux Platform -->
|
||||
<PackageReference Include="OpenMaui.Controls.Linux" Version="1.0.0-preview.1" />
|
||||
|
||||
<!-- Core MAUI packages -->
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.40" />
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using Microsoft.Maui.Platform.Linux;
|
||||
using OpenMaui.Platform.Linux.Linux;
|
||||
|
||||
namespace MauiLinuxApp;
|
||||
namespace OpenMauiLinuxApp;
|
||||
|
||||
public class Program
|
||||
{
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/template",
|
||||
"author": "MarketAlly LLC",
|
||||
"classifications": ["MAUI", "Linux", "Desktop", "App", "OpenMaui", "XAML"],
|
||||
"identity": "OpenMaui.Linux.XamlApp",
|
||||
"name": "OpenMaui Linux XAML Application",
|
||||
"shortName": "openmaui-linux-xaml",
|
||||
"description": "A .NET MAUI application for Linux using standard XAML syntax with OpenMaui platform support.",
|
||||
"tags": {
|
||||
"language": "C#",
|
||||
"type": "project"
|
||||
},
|
||||
"sourceName": "OpenMauiXamlApp",
|
||||
"preferNameDirectory": true,
|
||||
"symbols": {
|
||||
"Framework": {
|
||||
"type": "parameter",
|
||||
"description": "The target framework for the project.",
|
||||
"datatype": "choice",
|
||||
"choices": [
|
||||
{
|
||||
"choice": "net9.0",
|
||||
"description": "Target .NET 9.0"
|
||||
}
|
||||
],
|
||||
"defaultValue": "net9.0",
|
||||
"replaces": "net9.0"
|
||||
}
|
||||
},
|
||||
"primaryOutputs": [
|
||||
{ "path": "OpenMauiXamlApp.csproj" }
|
||||
],
|
||||
"postActions": [
|
||||
{
|
||||
"description": "Restore NuGet packages required by this project.",
|
||||
"manualInstructions": [{ "text": "Run 'dotnet restore'" }],
|
||||
"actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025",
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="OpenMauiXamlApp.App">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
|
||||
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace OpenMauiXamlApp;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
MainPage = new MainPage();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="OpenMauiXamlApp.MainPage"
|
||||
Title="Home">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout
|
||||
Padding="30,0"
|
||||
Spacing="25"
|
||||
VerticalOptions="Center">
|
||||
|
||||
<Image
|
||||
Source="dotnet_bot.png"
|
||||
HeightRequest="185"
|
||||
Aspect="AspectFit"
|
||||
SemanticProperties.Description="dot net bot waving hi to you!" />
|
||||
|
||||
<Label
|
||||
Text="Hello, OpenMaui!"
|
||||
Style="{StaticResource Headline}"
|
||||
SemanticProperties.HeadingLevel="Level1"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<Label
|
||||
Text="Welcome to .NET MAUI on Linux"
|
||||
Style="{StaticResource SubHeadline}"
|
||||
SemanticProperties.Description="Welcome to dot net MAUI on Linux"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<Button
|
||||
x:Name="CounterBtn"
|
||||
Text="Click me"
|
||||
SemanticProperties.Hint="Counts the number of times you click"
|
||||
Clicked="OnCounterClicked"
|
||||
HorizontalOptions="Fill" />
|
||||
|
||||
<HorizontalStackLayout
|
||||
Spacing="10"
|
||||
HorizontalOptions="Center">
|
||||
|
||||
<CheckBox
|
||||
x:Name="AgreeCheckBox"
|
||||
IsChecked="False" />
|
||||
<Label
|
||||
Text="I agree to the terms"
|
||||
VerticalOptions="Center" />
|
||||
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<Entry
|
||||
x:Name="NameEntry"
|
||||
Placeholder="Enter your name"
|
||||
HorizontalOptions="Fill" />
|
||||
|
||||
<Slider
|
||||
x:Name="VolumeSlider"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="50"
|
||||
HorizontalOptions="Fill" />
|
||||
|
||||
<Label
|
||||
x:Name="VolumeLabel"
|
||||
Text="Volume: 50"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<Switch
|
||||
x:Name="DarkModeSwitch"
|
||||
IsToggled="False"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<ProgressBar
|
||||
x:Name="LoadingProgress"
|
||||
Progress="0.5"
|
||||
ProgressColor="{StaticResource Primary}"
|
||||
HorizontalOptions="Fill" />
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</ContentPage>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue