Compare commits

...

17 Commits

Author SHA1 Message Date
Admin 7e58513ab3 Remove unsupported artifact upload
CI / Build and Test (push) Successful in 16s Details
2025-12-27 13:05:55 -05:00
Admin a450daa86f Remove pwsh dependency
CI / Build and Test (push) Failing after 43s Details
2025-12-27 12:22:24 -05:00
Admin c8840f2e8b Use explicit .NET 9 path in workflows
CI / Build and Test (push) Failing after 9s Details
2025-12-27 12:06:47 -05:00
Admin f0dbd29b58 Rebuild with fixed PATH
CI / Build and Test (push) Failing after 10s Details
2025-12-27 12:01:33 -05:00
Admin a4f04f4966 Rebuild with .NET 9 PATH
CI / Build and Test (push) Failing after 9s Details
2025-12-27 11:54:40 -05:00
Admin a03c600864 Rebuild with Node.js
CI / Build and Test (push) Failing after 11s Details
2025-12-27 11:48:09 -05:00
Admin 0c460c1395 Trigger CI build
CI / Build and Test (push) Failing after 24s Details
2025-12-27 11:44:46 -05:00
Admin 0dd7a2d3fb Add Gitea Actions workflows for CI and NuGet release 2025-12-27 11:34:23 -05:00
Admin afbf8f6782 Fix layout rendering, text wrapping, and scrollbar issues
- LinuxViewRenderer: Remove redundant child rendering that caused "View already has a parent" errors
- SkiaLabel: Consider LineBreakMode.WordWrap for multi-line measurement and rendering
- SkiaScrollView: Update ContentSize in OnDraw with properly constrained measurement
- SkiaShell: Account for padding in MeasureOverride (consistent with ArrangeOverride)
- Version bump to 1.0.0-preview.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:20:27 -05:00
Admin 02b3da17d4 Update repository URLs to git.marketally.com
Migrated from GitHub to self-hosted Gitea server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:45:26 -05:00
logikonline 299914d077 Add package icon for NuGet
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 14:08:27 -05:00
logikonline ed09456d57 Move samples to dedicated repository
Samples are now in https://github.com/open-maui/maui-linux-samples

This keeps the framework repo focused and allows samples to:
- Reference NuGet package (real-world usage)
- Be cloned independently
- Have their own release cycle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 13:41:52 -05:00
logikonline 1d55ac672a Preview 3: Complete control implementation with XAML data binding
Major milestone adding full control functionality:

Controls Enhanced:
- Entry/Editor: Full keyboard input, cursor navigation, selection, clipboard
- CollectionView: Data binding, selection highlighting, scrolling
- CheckBox/Switch/Slider: Interactive state management
- Picker/DatePicker/TimePicker: Dropdown selection with popup overlays
- ProgressBar/ActivityIndicator: Animated progress display
- Button: Press/release visual states
- Border/Frame: Rounded corners, stroke styling
- Label: Text wrapping, alignment, decorations
- Grid/StackLayout: Margin and padding support

Features Added:
- DisplayAlert dialogs with button actions
- NavigationPage with toolbar and back navigation
- Shell with flyout menu navigation
- XAML value converters for data binding
- Margin support in all layout containers
- Popup overlay system for pickers

New Samples:
- TodoApp: Full CRUD task manager with NavigationPage
- ShellDemo: Comprehensive control showcase

Removed:
- ControlGallery (replaced by ShellDemo)
- LinuxDemo (replaced by TodoApp)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 13:26:56 -05:00
logikonline f945d2a537 Add control gallery sample and roadmap documentation
- Add comprehensive ControlGallery sample app with 12 pages
  demonstrating all 35+ controls
- Add detailed ROADMAP.md with version milestones
- Add README placeholders for VSIX icons and template images
- Sample pages include: Home, Buttons, Labels, Entry, Pickers,
  Sliders, Toggles, Progress, Images, CollectionView, CarouselView,
  SwipeView, RefreshView

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 05:24:35 -05:00
logikonline 1d9338d823 Add full XAML support for .NET MAUI compatibility
New features:
- MauiAppBuilderExtensions.UseOpenMauiLinux() for standard MAUI integration
- New template: openmaui-linux-xaml with full XAML support
- Standard MAUI XAML syntax (ContentPage, VerticalStackLayout, etc.)
- Resource dictionaries (Colors.xaml, Styles.xaml)
- Compiled XAML support via MauiXaml items

Templates now available:
- dotnet new openmaui-linux      (code-based UI)
- dotnet new openmaui-linux-xaml (XAML-based UI)

Usage:
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseOpenMauiLinux();  // Enable Linux with XAML
2025-12-19 05:17:50 -05:00
logikonline ae5c9ab738 Add Visual Studio extension for Linux platform support
VSIX extension that adds:
- "OpenMaui Linux App" project template in File → New → Project
- Pre-configured launch profiles for Linux debugging
- WSL integration for Windows developers
- x64 and ARM64 build configurations

Launch Profiles included:
- Linux (Local) - Direct execution
- Linux (WSL) - Run via WSL
- Linux (x64 Release) - Release build for x64
- Linux (ARM64 Release) - Release build for ARM64
- Publish Linux x64/ARM64 - Self-contained publishing

Build with: msbuild /p:Configuration=Release
Output: OpenMaui.VisualStudio.vsix
2025-12-19 05:13:16 -05:00
logikonline d238dde5a4 Add FAQ documentation for Visual Studio integration
- How to add Linux to existing MAUI projects
- Why Linux doesn't appear in VS platform dropdown
- Building for Linux from Windows (CLI, WSL, VM)
- Debugging options (remote, WSL, VM)
- Project structure recommendations
- Build and packaging instructions
- Common issues and solutions
- IDE recommendations
- CI/CD examples
2025-12-19 05:07:57 -05:00
113 changed files with 36635 additions and 2665 deletions

37
.gitea/workflows/ci.yml Normal file
View File

@ -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\

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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();
}
}
}

View File

@ -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
};
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}
}

View File

@ -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)

104
Handlers/FrameHandler.cs Normal file
View File

@ -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);
}
}
}
}

View File

@ -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();
}
}
}

View File

@ -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
};
}
}

View File

@ -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>

View File

@ -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));
}
}
}

View File

@ -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);
}

View File

@ -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
{

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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}");
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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>

299
Hosting/LinuxMauiContext.cs Normal file
View File

@ -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();
}
}

View File

@ -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
});

View File

@ -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!;
}
}

View File

@ -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&lt;App&gt;()
/// .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;
}
}

View File

@ -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
};
}
}

View File

@ -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>

View File

@ -12,19 +12,20 @@
<!-- NuGet Package Properties -->
<PackageId>OpenMaui.Controls.Linux</PackageId>
<Version>1.0.0-preview.1</Version>
<Version>1.0.0-preview.4</Version>
<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/open-maui/maui-linux</PackageProjectUrl>
<RepositoryUrl>https://github.com/open-maui/maui-linux.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;openmaui</PackageTags>
<PackageReleaseNotes>Initial preview release with 35+ controls and full platform services.</PackageReleaseNotes>
<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.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" />
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="3.119.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="8.3.1.2" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.2" />
<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>

View File

@ -2,7 +2,6 @@
A comprehensive Linux platform implementation for .NET MAUI using SkiaSharp rendering.
[![Build Status](https://github.com/open-maui/maui-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/open-maui/maui-linux/actions)
[![NuGet](https://img.shields.io/nuget/v/OpenMaui.Controls.Linux)](https://www.nuget.org/packages/OpenMaui.Controls.Linux)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@ -27,14 +26,14 @@ This project brings .NET MAUI to Linux desktops with native X11/Wayland support,
### Installation
```bash
# Install the template
# Install the templates
dotnet new install OpenMaui.Linux.Templates
# Create a new project
dotnet new openmaui-linux -n MyApp
cd MyApp
# 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)
# Run
cd MyApp
dotnet run
```
@ -44,6 +43,32 @@ dotnet run
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
| Category | Controls |
@ -104,10 +129,20 @@ 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 OpenMaui.Platform.Linux;
@ -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

View File

@ -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

View File

@ -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();
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 } }
};
}

248
Services/SystemClipboard.cs Normal file
View File

@ -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
}
}
}

View File

@ -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)

385
Views/SkiaAlertDialog.cs Normal file
View File

@ -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);
}
}
}

View File

@ -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;
}
}

66
Views/SkiaBoxView.cs Normal file
View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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
};

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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;
}

View File

@ -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>

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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; }

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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

View File

@ -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; }

View File

@ -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;
}

View File

@ -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; }

367
Views/SkiaTemplatedView.cs Normal file
View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
}
}

695
Views/SkiaWebView.cs Normal file
View File

@ -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

1334
Window/WaylandWindow.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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()));
}

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

307
docs/FAQ.md Normal file
View File

@ -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.*

96
docs/ROADMAP.md Normal file
View File

@ -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*

19333
out.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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; }
}

View File

@ -7,8 +7,12 @@
<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.</Description>
<PackageTags>dotnet-new;templates;maui;linux;desktop;openmaui</PackageTags>
<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>
@ -23,7 +27,10 @@
</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>

View File

@ -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
}
]
}

View File

@ -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>

View File

@ -0,0 +1,10 @@
namespace OpenMauiXamlApp;
public partial class App : Application
{
public App()
{
InitializeComponent();
MainPage = new MainPage();
}
}

View File

@ -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>

View File

@ -0,0 +1,30 @@
namespace OpenMauiXamlApp;
public partial class MainPage : ContentPage
{
private int _count = 0;
public MainPage()
{
InitializeComponent();
// Wire up slider value changed
VolumeSlider.ValueChanged += OnVolumeChanged;
}
private void OnCounterClicked(object sender, EventArgs e)
{
_count++;
CounterBtn.Text = _count == 1
? $"Clicked {_count} time"
: $"Clicked {_count} times";
SemanticScreenReader.Announce(CounterBtn.Text);
}
private void OnVolumeChanged(object? sender, ValueChangedEventArgs e)
{
VolumeLabel.Text = $"Volume: {e.NewValue:F0}";
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
using OpenMaui.Platform.Linux.Hosting;
namespace OpenMauiXamlApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseOpenMauiLinux() // Enable Linux platform with full XAML support
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
return builder.Build();
}
}

View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<RootNamespace>OpenMauiXamlApp</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Enable XAML compilation -->
<EnableDefaultXamlItems>true</EnableDefaultXamlItems>
<!-- Linux Runtime -->
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<!-- OpenMaui Linux Platform -->
<PackageReference Include="OpenMaui.Controls.Linux" Version="1.0.0-preview.*" />
<!-- Core MAUI packages (includes XAML support) -->
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.*" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.*" />
</ItemGroup>
<!-- XAML Files -->
<ItemGroup>
<MauiXaml Update="**/*.xaml" />
<MauiXaml Update="App.xaml" />
<MauiXaml Update="MainPage.xaml" />
</ItemGroup>
<!-- Embedded Resources -->
<ItemGroup>
<EmbeddedResource Include="Resources\**\*" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
using OpenMaui.Platform.Linux;
namespace OpenMauiXamlApp;
public class Program
{
public static void Main(string[] args)
{
// Create the MAUI app using standard MAUI bootstrapping
var app = MauiProgram.CreateMauiApp();
// Run with Linux platform
// This connects MAUI's virtual views to our Skia platform views
LinuxApplication.Run(app);
}
}

View File

@ -0,0 +1,4 @@
# Add your fonts here
# Recommended:
# - OpenSans-Regular.ttf
# - OpenSans-Semibold.ttf

View File

@ -0,0 +1,2 @@
# Add your images here
# Required: dotnet_bot.png (from MAUI template)

View File

@ -0,0 +1,22 @@
# Image Resources
## Required Images
### dotnet_bot.png
Download from the official .NET MAUI repository:
https://github.com/dotnet/maui/blob/main/src/Templates/src/templates/maui-mobile/Resources/Images/dotnet_bot.png
Or use your own application icon.
## Adding Images
Place images in this folder and reference them in XAML:
```xml
<Image Source="dotnet_bot.png" WidthRequest="250" HeightRequest="310" />
```
Images are automatically included via the project file:
```xml
<MauiImage Include="Resources\Images\*" />
```

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Primary Colors -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#3B1F9E</Color>
<Color x:Key="PrimaryLight">#7B5EDF</Color>
<!-- Secondary Colors -->
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDark">#9880E5</Color>
<!-- Accent Colors -->
<Color x:Key="Accent">#FF6B35</Color>
<!-- Neutral Colors -->
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<!-- Semantic Colors -->
<Color x:Key="Success">#4CAF50</Color>
<Color x:Key="Warning">#FF9800</Color>
<Color x:Key="Error">#F44336</Color>
<Color x:Key="Info">#2196F3</Color>
<!-- Light Theme -->
<Color x:Key="LightBackground">White</Color>
<Color x:Key="LightSurface">#F5F5F5</Color>
<Color x:Key="LightOnBackground">#212121</Color>
<Color x:Key="LightOnSurface">#424242</Color>
<!-- Dark Theme -->
<Color x:Key="DarkBackground">#121212</Color>
<Color x:Key="DarkSurface">#1E1E1E</Color>
<Color x:Key="DarkOnBackground">#FFFFFF</Color>
<Color x:Key="DarkOnSurface">#E0E0E0</Color>
</ResourceDictionary>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Headline Style -->
<Style x:Key="Headline" TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Primary}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="FontAttributes" Value="Bold" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<!-- SubHeadline Style -->
<Style x:Key="SubHeadline" TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Gray500}" />
<Setter Property="FontSize" Value="18" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<!-- Default Button Style -->
<Style TargetType="Button">
<Setter Property="TextColor" Value="White" />
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontAttributes" Value="Bold" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="MinimumHeightRequest" Value="44" />
<Setter Property="MinimumWidthRequest" Value="44" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{StaticResource Gray300}" />
<Setter Property="BackgroundColor" Value="{StaticResource Gray100}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{StaticResource PrimaryDark}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!-- Default Entry Style -->
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{StaticResource Gray900}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray400}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44" />
</Style>
<!-- Default Label Style -->
<Style TargetType="Label">
<Setter Property="TextColor" Value="{StaticResource Gray900}" />
<Setter Property="FontSize" Value="14" />
</Style>
<!-- Default CheckBox Style -->
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{StaticResource Primary}" />
</Style>
<!-- Default Switch Style -->
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{StaticResource Primary}" />
<Setter Property="ThumbColor" Value="White" />
</Style>
<!-- Default Slider Style -->
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{StaticResource Primary}" />
<Setter Property="MaximumTrackColor" Value="{StaticResource Gray200}" />
<Setter Property="ThumbColor" Value="{StaticResource Primary}" />
</Style>
<!-- Default ProgressBar Style -->
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{StaticResource Primary}" />
</Style>
<!-- Default ActivityIndicator Style -->
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{StaticResource Primary}" />
</Style>
<!-- Page Background -->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="BackgroundColor" Value="{StaticResource LightBackground}" />
</Style>
<Style TargetType="ContentPage" ApplyToDerivedTypes="True">
<Setter Property="BackgroundColor" Value="{StaticResource LightBackground}" />
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,23 @@
MIT License
Copyright (c) 2025 MarketAlly LLC
Lead Architect: David H. Friedel Jr.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More