Compare commits

...

22 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
logikonline 071df4eb28 Rename package to OpenMaui.Controls.Linux
- Changed package ID from Microsoft.Maui.Controls.Linux to OpenMaui.Controls.Linux
- Updated all project files, namespaces, and documentation
- Renamed template from maui-linux to openmaui-linux
- Updated NuGet badges and install commands
- Maintained MarketAlly LLC as owner/author

Package: OpenMaui.Controls.Linux
Template: OpenMaui.Linux.Templates
Install: dotnet add package OpenMaui.Controls.Linux --prerelease
2025-12-19 05:01:34 -05:00
dependabot[bot] 2535f16893
Bump the skiasharp group with 5 updates (#5)
Bumps HarfBuzzSharp from 7.3.0.3 to 8.3.1.2
Bumps HarfBuzzSharp.NativeAssets.Linux from 7.3.0.3 to 8.3.1.2
Bumps SkiaSharp from 3.116.1 to 3.119.1
Bumps SkiaSharp.NativeAssets.Linux from 3.116.1 to 3.119.1
Bumps SkiaSharp.Views.Desktop.Common from 3.116.1 to 3.119.1

---
updated-dependencies:
- dependency-name: HarfBuzzSharp
  dependency-version: 8.3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: skiasharp
- dependency-name: HarfBuzzSharp.NativeAssets.Linux
  dependency-version: 8.3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: skiasharp
- dependency-name: SkiaSharp
  dependency-version: 3.119.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: skiasharp
- dependency-name: SkiaSharp.NativeAssets.Linux
  dependency-version: 3.119.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: skiasharp
- dependency-name: SkiaSharp.Views.Desktop.Common
  dependency-version: 3.119.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: skiasharp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 04:53:01 -05:00
dependabot[bot] 924d0ae874
Bump actions/checkout from 4 to 6 (#3)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 04:52:58 -05:00
dependabot[bot] 7172907333
Bump softprops/action-gh-release from 1 to 2 (#2)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 04:52:55 -05:00
dependabot[bot] a4d9d2c1eb
Bump actions/upload-artifact from 4 to 6 (#1)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 04:52:52 -05:00
124 changed files with 36709 additions and 2737 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

@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4
@ -42,14 +42,14 @@ jobs:
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Upload test results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: test-results
path: '**/TestResults/*.trx'
- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: build-artifacts
path: |
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4
@ -83,7 +83,7 @@ jobs:
run: dotnet pack --configuration Release --no-restore -o ./nupkg
- name: Upload NuGet package
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: nuget-package
path: ./nupkg/*.nupkg
@ -94,7 +94,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4

View File

@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4
@ -60,7 +60,7 @@ jobs:
run: dotnet pack --configuration Release --no-build -o ./nupkg /p:PackageVersion=${{ steps.version.outputs.version }}
- name: Upload NuGet package artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: nuget-package-${{ steps.version.outputs.version }}
path: ./nupkg/*.nupkg
@ -77,7 +77,7 @@ jobs:
- name: Attach packages to release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
files: ./nupkg/*.nupkg
env:

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

@ -4,27 +4,28 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.Maui.Platform.Linux</RootNamespace>
<AssemblyName>Microsoft.Maui.Controls.Linux</AssemblyName>
<RootNamespace>OpenMaui.Platform.Linux</RootNamespace>
<AssemblyName>OpenMaui.Controls.Linux</AssemblyName>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS0108;CS1591;CS0618</NoWarn>
<!-- NuGet Package Properties -->
<PackageId>Microsoft.Maui.Controls.Linux</PackageId>
<PackageId>OpenMaui.Controls.Linux</PackageId>
<Version>1.0.0-preview.4</Version>
<Authors>MAUI Linux Community Contributors</Authors>
<Company>.NET Foundation</Company>
<Product>.NET MAUI Linux Controls</Product>
<Description>Linux desktop support for .NET MAUI applications using SkiaSharp rendering. Supports X11 and Wayland display servers.</Description>
<Copyright>Copyright 2024-2025 .NET Foundation and Contributors</Copyright>
<Authors>MarketAlly LLC, David H. Friedel Jr.</Authors>
<Company>MarketAlly LLC</Company>
<Product>OpenMaui Linux Controls</Product>
<Description>Linux desktop support for .NET MAUI applications using SkiaSharp rendering. Supports X11 and Wayland display servers with 35+ controls, platform services, and accessibility support.</Description>
<Copyright>Copyright 2025 MarketAlly LLC</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/dotnet/maui</PackageProjectUrl>
<RepositoryUrl>https://github.com/dotnet/maui.git</RepositoryUrl>
<PackageProjectUrl>https://git.marketally.com/open-maui/maui-linux</PackageProjectUrl>
<RepositoryUrl>https://git.marketally.com/open-maui/maui-linux.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>maui;linux;desktop;skia;gui;cross-platform;dotnet;x11;wayland</PackageTags>
<PackageReleaseNotes>Preview 2 with Image, ImageButton, and GraphicsView controls.</PackageReleaseNotes>
<PackageTags>maui;linux;desktop;skia;gui;cross-platform;dotnet;x11;wayland;openmaui</PackageTags>
<PackageReleaseNotes>Preview 4: Fixed handler rendering for layouts, text wrapping, and scrollbar measurement issues.</PackageReleaseNotes>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>icon.png</PackageIcon>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<IsPackable>true</IsPackable>
</PropertyGroup>
@ -35,27 +36,35 @@
<PackageReference Include="Microsoft.Maui.Graphics" Version="9.0.40" />
<PackageReference Include="Microsoft.Maui.Graphics.Skia" Version="9.0.40" />
<!-- SkiaSharp for rendering -->
<PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="3.116.1" />
<!-- SkiaSharp for rendering (2.88.x for FreeType compatibility) -->
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.Views.Desktop.Common" Version="2.88.9" />
<!-- HarfBuzz for advanced text shaping -->
<PackageReference Include="HarfBuzzSharp" Version="7.3.0.3" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.3" />
</ItemGroup>
<!-- Include README in package -->
<!-- Include README and icon in package -->
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
<None Include="assets/icon.png" Pack="true" PackagePath="" />
</ItemGroup>
<!-- Exclude old handler files and samples -->
<!-- Exclude old handler files, samples, templates, and VSIX -->
<ItemGroup>
<Compile Remove="Handlers/*.Linux.cs" />
<Compile Remove="samples/**/*.cs" />
<Compile Remove="tests/**/*.cs" />
<Compile Remove="templates/**/*.cs" />
<Compile Remove="vsix/**/*.cs" />
<None Remove="vsix/**/*.xaml" />
<None Remove="templates/**/*.xaml" />
<None Remove="samples/**/*.xaml" />
<MauiXaml Remove="vsix/**/*.xaml" />
<MauiXaml Remove="templates/**/*.xaml" />
<MauiXaml Remove="samples/**/*.xaml" />
</ItemGroup>
</Project>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Microsoft.Maui.Controls.Linux</id>
<id>OpenMaui.Controls.Linux</id>
<version>1.0.0-preview.1</version>
<title>.NET MAUI Linux Controls</title>
<title>OpenMaui Linux Controls</title>
<authors>MarketAlly LLC, David H. Friedel Jr.</authors>
<owners>MarketAlly LLC</owners>
<license type="expression">MIT</license>
@ -33,7 +33,7 @@ Initial release:
- 216 unit tests
</releaseNotes>
<copyright>Copyright 2025 MarketAlly LLC</copyright>
<tags>maui linux desktop skia gui cross-platform dotnet</tags>
<tags>maui linux desktop skia gui cross-platform dotnet openmaui</tags>
<repository type="git" url="https://github.com/open-maui/maui-linux.git" />
<dependencies>
<group targetFramework="net9.0">
@ -47,8 +47,8 @@ Initial release:
</frameworkAssemblies>
</metadata>
<files>
<file src="bin/Release/net9.0/Microsoft.Maui.Controls.Linux.dll" target="lib/net9.0" />
<file src="bin/Release/net9.0/Microsoft.Maui.Controls.Linux.xml" target="lib/net9.0" />
<file src="bin/Release/net9.0/OpenMaui.Controls.Linux.dll" target="lib/net9.0" />
<file src="bin/Release/net9.0/OpenMaui.Controls.Linux.xml" target="lib/net9.0" />
<file src="README.md" target="" />
</files>
</package>

View File

@ -1,9 +1,8 @@
# .NET MAUI Linux Platform
# OpenMaui Linux Platform
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/Microsoft.Maui.Controls.Linux)](https://www.nuget.org/packages/Microsoft.Maui.Controls.Linux)
[![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)
**Developed by [MarketAlly LLC](https://marketally.com)**
@ -27,21 +26,47 @@ This project brings .NET MAUI to Linux desktops with native X11/Wayland support,
### Installation
```bash
# Install the template
dotnet new install Microsoft.Maui.Linux.Templates
# Install the templates
dotnet new install OpenMaui.Linux.Templates
# Create a new project (choose one):
dotnet new openmaui-linux -n MyApp # Code-based UI
dotnet new openmaui-linux-xaml -n MyApp # XAML-based UI (recommended)
# Create a new project
dotnet new maui-linux -n MyApp
cd MyApp
# Run
dotnet run
```
### Manual Installation
```bash
dotnet add package Microsoft.Maui.Controls.Linux --prerelease
dotnet add package OpenMaui.Controls.Linux --prerelease
```
## XAML Support
OpenMaui fully supports standard .NET MAUI XAML syntax. Use the familiar XAML workflow:
```xml
<!-- MainPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MyApp.MainPage">
<VerticalStackLayout>
<Label Text="Hello, OpenMaui!" FontSize="32" />
<Button Text="Click me" Clicked="OnButtonClicked" />
<Entry Placeholder="Enter text..." />
<Slider Minimum="0" Maximum="100" />
</VerticalStackLayout>
</ContentPage>
```
```csharp
// MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseOpenMauiLinux(); // Enable Linux with XAML support
```
## Supported Controls
@ -104,13 +129,23 @@ sudo dnf install libX11-devel libXrandr-devel libXcursor-devel libXi-devel mesa-
## Documentation
- [Getting Started Guide](docs/GETTING_STARTED.md)
- [FAQ - Visual Studio Integration](docs/FAQ.md)
- [API Reference](docs/API.md)
- [Contributing Guide](CONTRIBUTING.md)
## Sample Application
## Sample Applications
Full sample applications are available in the [maui-linux-samples](https://git.marketally.com/open-maui/maui-linux-samples) repository:
| Sample | Description |
|--------|-------------|
| **[TodoApp](https://git.marketally.com/open-maui/maui-linux-samples/src/branch/main/TodoApp)** | Task manager with NavigationPage, XAML data binding, CollectionView |
| **[ShellDemo](https://git.marketally.com/open-maui/maui-linux-samples/src/branch/main/ShellDemo)** | Control showcase with Shell navigation and flyout menu |
## Quick Example
```csharp
using Microsoft.Maui.Platform;
using OpenMaui.Platform.Linux;
var app = new LinuxApplication();
@ -144,7 +179,7 @@ app.Run();
## Building from Source
```bash
git clone https://github.com/open-maui/maui-linux.git
git clone https://git.marketally.com/open-maui/maui-linux.git
cd maui-linux
dotnet build
dotnet test
@ -198,3 +233,7 @@ Copyright (c) 2025 MarketAlly LLC. Licensed under the MIT License - see the [LIC
- [SkiaSharp](https://github.com/mono/SkiaSharp) - 2D graphics library
- [.NET MAUI](https://github.com/dotnet/maui) - Cross-platform UI framework
- The .NET community

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

View File

@ -31,7 +31,7 @@ sudo pacman -S libx11 libxrandr libxcursor libxi mesa fontconfig
1. Install the template:
```bash
dotnet new install Microsoft.Maui.Linux.Templates
dotnet new install OpenMaui.Linux.Templates
```
2. Create a new project:
@ -55,13 +55,13 @@ cd MyMauiLinuxApp
2. Add the NuGet package:
```bash
dotnet add package Microsoft.Maui.Controls.Linux --prerelease
dotnet add package OpenMaui.Controls.Linux --prerelease
```
3. Update your `Program.cs`:
```csharp
using Microsoft.Maui.Platform;
using Microsoft.Maui.Platform.Linux;
using OpenMaui.Platform.Linux;
var app = new LinuxApplication();

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

@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageType>Template</PackageType>
<PackageVersion>1.0.0-preview.4</PackageVersion>
<PackageId>Microsoft.Maui.Linux.Templates</PackageId>
<Title>.NET MAUI Linux Project Templates</Title>
<Authors>MAUI Linux Community Contributors</Authors>
<Description>Project templates for building .NET MAUI applications on Linux desktop.</Description>
<PackageTags>dotnet-new;templates;maui;linux;desktop</PackageTags>
<PackageProjectUrl>https://github.com/dotnet/maui</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<Content Include="maui-linux-app\**\*" Exclude="maui-linux-app\**\bin\**;maui-linux-app\**\obj\**" />
<Compile Remove="**\*" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageType>Template</PackageType>
<PackageVersion>1.0.0-preview.1</PackageVersion>
<PackageId>OpenMaui.Linux.Templates</PackageId>
<Title>OpenMaui Linux Project Templates</Title>
<Authors>MarketAlly LLC, David H. Friedel Jr.</Authors>
<Company>MarketAlly LLC</Company>
<Description>Project templates for building .NET MAUI applications on Linux desktop using OpenMaui.
Templates included:
- openmaui-linux: Basic Linux app with code-based UI
- openmaui-linux-xaml: Full XAML support with standard MAUI syntax</Description>
<PackageTags>dotnet-new;templates;maui;linux;desktop;openmaui;xaml</PackageTags>
<PackageProjectUrl>https://github.com/open-maui/maui-linux</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>Copyright 2025 MarketAlly LLC</Copyright>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<!-- Code-based template -->
<Content Include="openmaui-linux-app\**\*" Exclude="openmaui-linux-app\**\bin\**;openmaui-linux-app\**\obj\**" />
<!-- XAML-based template -->
<Content Include="openmaui-linux-xaml-app\**\*" Exclude="openmaui-linux-xaml-app\**\bin\**;openmaui-linux-xaml-app\**\obj\**" />
<Compile Remove="**\*" />
</ItemGroup>
</Project>

View File

@ -1,15 +1,15 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "MAUI Linux Community",
"classifications": ["MAUI", "Linux", "Desktop", "App"],
"identity": "Microsoft.Maui.Linux.App",
"name": ".NET MAUI Linux Application",
"shortName": "maui-linux",
"author": "MarketAlly LLC",
"classifications": ["MAUI", "Linux", "Desktop", "App", "OpenMaui"],
"identity": "OpenMaui.Linux.App",
"name": "OpenMaui Linux Application",
"shortName": "openmaui-linux",
"tags": {
"language": "C#",
"type": "project"
},
"sourceName": "MauiLinuxApp",
"sourceName": "OpenMauiLinuxApp",
"preferNameDirectory": true,
"symbols": {
"Framework": {
@ -57,7 +57,7 @@
}
},
"primaryOutputs": [
{ "path": "MauiLinuxApp.csproj" }
{ "path": "OpenMauiLinuxApp.csproj" }
],
"postActions": [
{

View File

@ -3,7 +3,7 @@
using Microsoft.Maui.Controls;
namespace MauiLinuxApp;
namespace OpenMauiLinuxApp;
public class App : Application
{

View File

@ -4,7 +4,7 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
namespace MauiLinuxApp;
namespace OpenMauiLinuxApp;
public class MainPage : ContentPage
{
@ -13,7 +13,7 @@ public class MainPage : ContentPage
public MainPage()
{
Title = "MauiLinuxApp";
Title = "OpenMauiLinuxApp";
_counterLabel = new Label
{

View File

@ -5,14 +5,14 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MauiLinuxApp</RootNamespace>
<AssemblyName>MauiLinuxApp</AssemblyName>
<ApplicationTitle>MauiLinuxApp</ApplicationTitle>
<RootNamespace>OpenMauiLinuxApp</RootNamespace>
<AssemblyName>OpenMauiLinuxApp</AssemblyName>
<ApplicationTitle>OpenMauiLinuxApp</ApplicationTitle>
</PropertyGroup>
<ItemGroup>
<!-- .NET MAUI Linux Platform -->
<PackageReference Include="Microsoft.Maui.Controls.Linux" Version="1.0.0-preview.4" />
<!-- OpenMaui Linux Platform -->
<PackageReference Include="OpenMaui.Controls.Linux" Version="1.0.0-preview.1" />
<!-- Core MAUI packages -->
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.40" />

View File

@ -1,9 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Maui.Platform.Linux;
using OpenMaui.Platform.Linux.Linux;
namespace MauiLinuxApp;
namespace OpenMauiLinuxApp;
public class Program
{

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>

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