Major production merge: GTK support, context menus, and dispatcher fixes

Core Infrastructure:
- Add Dispatching folder with LinuxDispatcher, LinuxDispatcherProvider, LinuxDispatcherTimer
- Add Native folder with P/Invoke wrappers (GTK, GLib, GDK, Cairo, WebKit)
- Add GTK host window system with GtkHostWindow and GtkSkiaSurfaceWidget
- Update LinuxApplication with GTK mode, theme handling, and icon support
- Fix duplicate LinuxDispatcher in LinuxMauiContext

Handlers:
- Add GtkWebViewManager and GtkWebViewPlatformView for GTK WebView
- Add FlexLayoutHandler and GestureManager
- Update multiple handlers with ToViewHandler fix and missing mappers
- Add MauiHandlerExtensions with ToViewHandler extension method

Views:
- Add SkiaContextMenu with hover, keyboard, and dark theme support
- Add LinuxDialogService with context menu management
- Add SkiaFlexLayout for flex container support
- Update SkiaShell with RefreshTheme, MauiShell, ContentRenderer
- Update SkiaWebView with SetMainWindow, ProcessGtkEvents
- Update SkiaImage with LoadFromBitmap method

Services:
- Add AppInfoService, ConnectivityService, DeviceDisplayService, DeviceInfoService
- Add GtkHostService, GtkContextMenuService, MauiIconGenerator

Window:
- Add CursorType enum and GtkHostWindow
- Update X11Window with SetIcon, SetCursor methods

Build: SUCCESS (0 errors)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 11:19:58 -05:00
parent e02af03be0
commit f7043ab9c7
56 changed files with 6061 additions and 473 deletions

101
Views/LinuxDialogService.cs Normal file
View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public static class LinuxDialogService
{
private static readonly List<SkiaAlertDialog> _activeDialogs = new List<SkiaAlertDialog>();
private static Action? _invalidateCallback;
private static SkiaContextMenu? _activeContextMenu;
private static Action? _showPopupCallback;
private static Action? _hidePopupCallback;
public static bool HasActiveDialog => _activeDialogs.Count > 0;
public static SkiaAlertDialog? TopDialog
{
get
{
if (_activeDialogs.Count <= 0)
{
return null;
}
return _activeDialogs[_activeDialogs.Count - 1];
}
}
public static SkiaContextMenu? ActiveContextMenu => _activeContextMenu;
public static bool HasContextMenu => _activeContextMenu != null;
public static void SetInvalidateCallback(Action callback)
{
_invalidateCallback = callback;
}
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;
}
internal static void HideDialog(SkiaAlertDialog dialog)
{
_activeDialogs.Remove(dialog);
_invalidateCallback?.Invoke();
}
public static void DrawDialogs(SKCanvas canvas, SKRect bounds)
{
DrawDialogsOnly(canvas, bounds);
DrawContextMenuOnly(canvas, bounds);
}
public static void DrawDialogsOnly(SKCanvas canvas, SKRect bounds)
{
foreach (var dialog in _activeDialogs)
{
dialog.Measure(new SKSize(bounds.Width, bounds.Height));
dialog.Arrange(bounds);
dialog.Draw(canvas);
}
}
public static void DrawContextMenuOnly(SKCanvas canvas, SKRect bounds)
{
if (_activeContextMenu != null)
{
_activeContextMenu.Draw(canvas);
}
}
public static void SetPopupCallbacks(Action showPopup, Action hidePopup)
{
_showPopupCallback = showPopup;
_hidePopupCallback = hidePopup;
}
public static void ShowContextMenu(SkiaContextMenu menu)
{
Console.WriteLine("[LinuxDialogService] ShowContextMenu called");
_activeContextMenu = menu;
_showPopupCallback?.Invoke();
_invalidateCallback?.Invoke();
}
public static void HideContextMenu()
{
_activeContextMenu = null;
_hidePopupCallback?.Invoke();
_invalidateCallback?.Invoke();
}
}

View File

@@ -323,63 +323,3 @@ public class SkiaAlertDialog : SkiaView
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

@@ -199,6 +199,39 @@ public class SkiaButton : SkiaView
typeof(SkiaButton),
null);
/// <summary>
/// Bindable property for ImageSource.
/// </summary>
public static readonly BindableProperty ImageSourceProperty =
BindableProperty.Create(
nameof(ImageSource),
typeof(SKBitmap),
typeof(SkiaButton),
null,
propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate());
/// <summary>
/// Bindable property for ImageSpacing.
/// </summary>
public static readonly BindableProperty ImageSpacingProperty =
BindableProperty.Create(
nameof(ImageSpacing),
typeof(float),
typeof(SkiaButton),
8f,
propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure());
/// <summary>
/// Bindable property for ContentLayoutPosition (0=Left, 1=Top, 2=Right, 3=Bottom).
/// </summary>
public static readonly BindableProperty ContentLayoutPositionProperty =
BindableProperty.Create(
nameof(ContentLayoutPosition),
typeof(int),
typeof(SkiaButton),
0,
propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure());
#endregion
#region Properties
@@ -356,6 +389,33 @@ public class SkiaButton : SkiaView
set => SetValue(CommandParameterProperty, value);
}
/// <summary>
/// Gets or sets the image source for the button.
/// </summary>
public SKBitmap? ImageSource
{
get => (SKBitmap?)GetValue(ImageSourceProperty);
set => SetValue(ImageSourceProperty, value);
}
/// <summary>
/// Gets or sets the spacing between the image and text.
/// </summary>
public float ImageSpacing
{
get => (float)GetValue(ImageSpacingProperty);
set => SetValue(ImageSpacingProperty, value);
}
/// <summary>
/// Gets or sets the content layout position (0=Left, 1=Top, 2=Right, 3=Bottom).
/// </summary>
public int ContentLayoutPosition
{
get => (int)GetValue(ContentLayoutPositionProperty);
set => SetValue(ContentLayoutPositionProperty, value);
}
/// <summary>
/// Gets whether the button is currently pressed.
/// </summary>
@@ -504,53 +564,151 @@ public class SkiaButton : SkiaView
canvas.DrawRoundRect(focusRect, focusPaint);
}
// Draw text
if (!string.IsNullOrEmpty(Text))
// Draw content (text and/or image)
DrawContent(canvas, bounds, isTextOnly);
}
private void DrawContent(SKCanvas canvas, SKRect bounds, bool isTextOnly)
{
var fontStyle = new SKFontStyle(
IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
?? SKTypeface.Default;
using var font = new SKFont(typeface, FontSize);
// Determine text color
SKColor textColorToUse;
if (!IsEnabled)
{
var fontStyle = new SKFontStyle(
IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
?? SKTypeface.Default;
textColorToUse = TextColor.WithAlpha(128);
}
else if (isTextOnly && (IsHovered || IsPressed))
{
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 font = new SKFont(typeface, FontSize);
using var textPaint = new SKPaint(font)
{
Color = textColorToUse,
IsAntialias = true
};
// For text-only buttons, darken text on hover/press for feedback
SKColor textColorToUse;
if (!IsEnabled)
// Measure text
var textBounds = new SKRect();
bool hasText = !string.IsNullOrEmpty(Text);
if (hasText)
{
textPaint.MeasureText(Text, ref textBounds);
}
// Calculate image size
bool hasImage = ImageSource != null;
float imageWidth = 0;
float imageHeight = 0;
if (hasImage)
{
float maxImageSize = Math.Min(bounds.Height - 8, 24f);
float scale = Math.Min(maxImageSize / ImageSource!.Width, maxImageSize / ImageSource.Height);
imageWidth = ImageSource.Width * scale;
imageHeight = ImageSource.Height * scale;
}
// Calculate total content size and position
bool isHorizontal = ContentLayoutPosition == 0 || ContentLayoutPosition == 2;
float totalWidth, totalHeight;
if (hasImage && hasText)
{
if (isHorizontal)
{
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);
totalWidth = imageWidth + ImageSpacing + textBounds.Width;
totalHeight = Math.Max(imageHeight, textBounds.Height);
}
else
{
textColorToUse = TextColor;
totalWidth = Math.Max(imageWidth, textBounds.Width);
totalHeight = imageHeight + ImageSpacing + textBounds.Height;
}
}
else if (hasImage)
{
totalWidth = imageWidth;
totalHeight = imageHeight;
}
else
{
totalWidth = textBounds.Width;
totalHeight = textBounds.Height;
}
float startX = bounds.MidX - totalWidth / 2;
float startY = bounds.MidY - totalHeight / 2;
// Draw image and text based on layout position
if (hasImage)
{
float imageX, imageY;
float textX = 0, textY = 0;
switch (ContentLayoutPosition)
{
case 1: // Top - image above text
imageX = bounds.MidX - imageWidth / 2;
imageY = startY;
textX = bounds.MidX - textBounds.Width / 2;
textY = startY + imageHeight + ImageSpacing - textBounds.Top;
break;
case 2: // Right - image to right of text
textX = startX;
textY = bounds.MidY - textBounds.MidY;
imageX = startX + textBounds.Width + ImageSpacing;
imageY = bounds.MidY - imageHeight / 2;
break;
case 3: // Bottom - image below text
textX = bounds.MidX - textBounds.Width / 2;
textY = startY - textBounds.Top;
imageX = bounds.MidX - imageWidth / 2;
imageY = startY + textBounds.Height + ImageSpacing;
break;
default: // 0 = Left - image to left of text
imageX = startX;
imageY = bounds.MidY - imageHeight / 2;
textX = startX + imageWidth + ImageSpacing;
textY = bounds.MidY - textBounds.MidY;
break;
}
using var paint = new SKPaint(font)
// Draw image
var imageRect = new SKRect(imageX, imageY, imageX + imageWidth, imageY + imageHeight);
using var imagePaint = new SKPaint { IsAntialias = true };
if (!IsEnabled)
{
Color = textColorToUse,
IsAntialias = true
};
imagePaint.ColorFilter = SKColorFilter.CreateBlendMode(new SKColor(128, 128, 128, 128), SKBlendMode.Modulate);
}
canvas.DrawBitmap(ImageSource!, imageRect, imagePaint);
// Measure text
var textBounds = new SKRect();
paint.MeasureText(Text, ref textBounds);
// Center text
// Draw text
if (hasText)
{
canvas.DrawText(Text!, textX, textY, textPaint);
}
}
else if (hasText)
{
// Just text, centered
var x = bounds.MidX - textBounds.MidX;
var y = bounds.MidY - textBounds.MidY;
canvas.DrawText(Text, x, y, paint);
canvas.DrawText(Text!, x, y, textPaint);
}
}

221
Views/SkiaContextMenu.cs Normal file
View File

@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public class SkiaContextMenu : SkiaView
{
private readonly List<ContextMenuItem> _items;
private readonly float _x;
private readonly float _y;
private int _hoveredIndex = -1;
private SKRect[] _itemBounds = Array.Empty<SKRect>();
private static readonly SKColor MenuBackground = new SKColor(255, 255, 255);
private static readonly SKColor MenuBackgroundDark = new SKColor(48, 48, 48);
private static readonly SKColor ItemHoverBackground = new SKColor(227, 242, 253);
private static readonly SKColor ItemHoverBackgroundDark = new SKColor(80, 80, 80);
private static readonly SKColor ItemTextColor = new SKColor(33, 33, 33);
private static readonly SKColor ItemTextColorDark = new SKColor(224, 224, 224);
private static readonly SKColor DisabledTextColor = new SKColor(158, 158, 158);
private static readonly SKColor SeparatorColor = new SKColor(224, 224, 224);
private static readonly SKColor ShadowColor = new SKColor(0, 0, 0, 40);
private const float MenuPadding = 4f;
private const float ItemHeight = 32f;
private const float ItemPaddingH = 16f;
private const float SeparatorHeight = 9f;
private const float CornerRadius = 4f;
private const float MinWidth = 120f;
private bool _isDarkTheme;
public SkiaContextMenu(float x, float y, List<ContextMenuItem> items, bool isDarkTheme = false)
{
_x = x;
_y = y;
_items = items;
_isDarkTheme = isDarkTheme;
IsFocusable = true;
}
public override void Draw(SKCanvas canvas)
{
float menuWidth = CalculateMenuWidth();
float menuHeight = CalculateMenuHeight();
float posX = _x;
float posY = _y;
// Adjust position to stay within bounds
canvas.GetDeviceClipBounds(out var clipBounds);
if (posX + menuWidth > clipBounds.Right)
{
posX = clipBounds.Right - menuWidth - 4f;
}
if (posY + menuHeight > clipBounds.Bottom)
{
posY = clipBounds.Bottom - menuHeight - 4f;
}
var menuRect = new SKRect(posX, posY, posX + menuWidth, posY + menuHeight);
// Draw shadow
using (var shadowPaint = new SKPaint
{
Color = ShadowColor,
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4f)
})
{
canvas.DrawRoundRect(menuRect.Left + 2f, menuRect.Top + 2f, menuWidth, menuHeight, CornerRadius, CornerRadius, shadowPaint);
}
// Draw background
using (var bgPaint = new SKPaint
{
Color = _isDarkTheme ? MenuBackgroundDark : MenuBackground,
IsAntialias = true
})
{
canvas.DrawRoundRect(menuRect, CornerRadius, CornerRadius, bgPaint);
}
// Draw border
using (var borderPaint = new SKPaint
{
Color = SeparatorColor,
Style = SKPaintStyle.Stroke,
StrokeWidth = 1f,
IsAntialias = true
})
{
canvas.DrawRoundRect(menuRect, CornerRadius, CornerRadius, borderPaint);
}
// Draw items
_itemBounds = new SKRect[_items.Count];
float itemY = posY + MenuPadding;
for (int i = 0; i < _items.Count; i++)
{
var item = _items[i];
if (item.IsSeparator)
{
float separatorY = itemY + SeparatorHeight / 2f;
using (var sepPaint = new SKPaint { Color = SeparatorColor, StrokeWidth = 1f })
{
canvas.DrawLine(posX + 8f, separatorY, posX + menuWidth - 8f, separatorY, sepPaint);
}
_itemBounds[i] = new SKRect(posX, itemY, posX + menuWidth, itemY + SeparatorHeight);
itemY += SeparatorHeight;
continue;
}
var itemRect = new SKRect(posX + MenuPadding, itemY, posX + menuWidth - MenuPadding, itemY + ItemHeight);
_itemBounds[i] = itemRect;
// Draw hover background
if (i == _hoveredIndex && item.IsEnabled)
{
using (var hoverPaint = new SKPaint
{
Color = _isDarkTheme ? ItemHoverBackgroundDark : ItemHoverBackground,
IsAntialias = true
})
{
canvas.DrawRoundRect(itemRect, CornerRadius, CornerRadius, hoverPaint);
}
}
// Draw text
using (var textPaint = new SKPaint
{
Color = !item.IsEnabled ? DisabledTextColor : (_isDarkTheme ? ItemTextColorDark : ItemTextColor),
TextSize = 14f,
IsAntialias = true,
Typeface = SKTypeface.Default
})
{
float textY = itemRect.MidY + textPaint.TextSize / 3f;
canvas.DrawText(item.Text, itemRect.Left + ItemPaddingH, textY, textPaint);
}
itemY += ItemHeight;
}
}
private float CalculateMenuWidth()
{
float maxWidth = MinWidth;
using (var paint = new SKPaint { TextSize = 14f, Typeface = SKTypeface.Default })
{
foreach (var item in _items)
{
if (!item.IsSeparator)
{
float textWidth = paint.MeasureText(item.Text) + ItemPaddingH * 2f;
maxWidth = Math.Max(maxWidth, textWidth);
}
}
}
return maxWidth + MenuPadding * 2f;
}
private float CalculateMenuHeight()
{
float height = MenuPadding * 2f;
foreach (var item in _items)
{
height += item.IsSeparator ? SeparatorHeight : ItemHeight;
}
return height;
}
public override void OnPointerMoved(PointerEventArgs e)
{
int oldHovered = _hoveredIndex;
_hoveredIndex = -1;
for (int i = 0; i < _itemBounds.Length; i++)
{
if (_itemBounds[i].Contains(e.X, e.Y) && !_items[i].IsSeparator)
{
_hoveredIndex = i;
break;
}
}
if (oldHovered != _hoveredIndex)
{
Invalidate();
}
}
public override void OnPointerPressed(PointerEventArgs e)
{
for (int i = 0; i < _itemBounds.Length; i++)
{
if (_itemBounds[i].Contains(e.X, e.Y))
{
var item = _items[i];
if (item.IsEnabled && !item.IsSeparator && item.Action != null)
{
LinuxDialogService.HideContextMenu();
item.Action();
return;
}
}
}
LinuxDialogService.HideContextMenu();
}
public override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
LinuxDialogService.HideContextMenu();
e.Handled = true;
}
}
}

View File

@@ -1,8 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Generic;
using SkiaSharp;
using Microsoft.Maui.Platform.Linux.Rendering;
using Microsoft.Maui.Platform.Linux.Services;
namespace Microsoft.Maui.Platform;
@@ -980,11 +982,17 @@ public class SkiaEntry : SkiaView
public override void OnPointerPressed(PointerEventArgs e)
{
Console.WriteLine($"[SkiaEntry] OnPointerPressed - Text='{Text}', Placeholder='{Placeholder}', IsEnabled={IsEnabled}, IsFocused={IsFocused}");
Console.WriteLine($"[SkiaEntry] Bounds={Bounds}, ScreenBounds={ScreenBounds}, e.X={e.X}, e.Y={e.Y}");
Console.WriteLine($"[SkiaEntry] OnPointerPressed Button={e.Button} at ({e.X}, {e.Y})");
if (!IsEnabled) return;
// Handle right-click context menu
if (e.Button == PointerButton.Right)
{
Console.WriteLine("[SkiaEntry] Right-click detected, showing context menu");
ShowContextMenu(e.X, e.Y);
return;
}
// Check if clicked on clear button
if (ShowClearButton && !string.IsNullOrEmpty(Text) && IsFocused)
{
@@ -1217,6 +1225,38 @@ public class SkiaEntry : SkiaView
Invalidate();
}
private void ShowContextMenu(float x, float y)
{
Console.WriteLine($"[SkiaEntry] ShowContextMenu at ({x}, {y})");
bool hasSelection = _selectionLength != 0;
bool hasText = !string.IsNullOrEmpty(Text);
bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
GtkContextMenuService.ShowContextMenu(new List<GtkMenuItem>
{
new GtkMenuItem("Cut", () =>
{
CutToClipboard();
Invalidate();
}, hasSelection),
new GtkMenuItem("Copy", () =>
{
CopyToClipboard();
}, hasSelection),
new GtkMenuItem("Paste", () =>
{
PasteFromClipboard();
Invalidate();
}, hasClipboard),
GtkMenuItem.Separator,
new GtkMenuItem("Select All", () =>
{
SelectAll();
Invalidate();
}, hasText)
});
}
public override void OnFocusGained()
{
base.OnFocusGained();

256
Views/SkiaFlexLayout.cs Normal file
View File

@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Maui.Controls;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public class SkiaFlexLayout : SkiaLayoutView
{
public static readonly BindableProperty DirectionProperty = BindableProperty.Create(
nameof(Direction), typeof(FlexDirection), typeof(SkiaFlexLayout), FlexDirection.Row,
BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure());
public static readonly BindableProperty WrapProperty = BindableProperty.Create(
nameof(Wrap), typeof(FlexWrap), typeof(SkiaFlexLayout), FlexWrap.NoWrap,
BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure());
public static readonly BindableProperty JustifyContentProperty = BindableProperty.Create(
nameof(JustifyContent), typeof(FlexJustify), typeof(SkiaFlexLayout), FlexJustify.Start,
BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure());
public static readonly BindableProperty AlignItemsProperty = BindableProperty.Create(
nameof(AlignItems), typeof(FlexAlignItems), typeof(SkiaFlexLayout), FlexAlignItems.Stretch,
BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure());
public static readonly BindableProperty AlignContentProperty = BindableProperty.Create(
nameof(AlignContent), typeof(FlexAlignContent), typeof(SkiaFlexLayout), FlexAlignContent.Stretch,
BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure());
public static readonly BindableProperty OrderProperty = BindableProperty.CreateAttached(
"Order", typeof(int), typeof(SkiaFlexLayout), 0, BindingMode.TwoWay);
public static readonly BindableProperty GrowProperty = BindableProperty.CreateAttached(
"Grow", typeof(float), typeof(SkiaFlexLayout), 0f, BindingMode.TwoWay);
public static readonly BindableProperty ShrinkProperty = BindableProperty.CreateAttached(
"Shrink", typeof(float), typeof(SkiaFlexLayout), 1f, BindingMode.TwoWay);
public static readonly BindableProperty BasisProperty = BindableProperty.CreateAttached(
"Basis", typeof(FlexBasis), typeof(SkiaFlexLayout), FlexBasis.Auto, BindingMode.TwoWay);
public static readonly BindableProperty AlignSelfProperty = BindableProperty.CreateAttached(
"AlignSelf", typeof(FlexAlignSelf), typeof(SkiaFlexLayout), FlexAlignSelf.Auto, BindingMode.TwoWay);
public FlexDirection Direction
{
get => (FlexDirection)GetValue(DirectionProperty);
set => SetValue(DirectionProperty, value);
}
public FlexWrap Wrap
{
get => (FlexWrap)GetValue(WrapProperty);
set => SetValue(WrapProperty, value);
}
public FlexJustify JustifyContent
{
get => (FlexJustify)GetValue(JustifyContentProperty);
set => SetValue(JustifyContentProperty, value);
}
public FlexAlignItems AlignItems
{
get => (FlexAlignItems)GetValue(AlignItemsProperty);
set => SetValue(AlignItemsProperty, value);
}
public FlexAlignContent AlignContent
{
get => (FlexAlignContent)GetValue(AlignContentProperty);
set => SetValue(AlignContentProperty, value);
}
public static int GetOrder(SkiaView view) => (int)view.GetValue(OrderProperty);
public static void SetOrder(SkiaView view, int value) => view.SetValue(OrderProperty, value);
public static float GetGrow(SkiaView view) => (float)view.GetValue(GrowProperty);
public static void SetGrow(SkiaView view, float value) => view.SetValue(GrowProperty, value);
public static float GetShrink(SkiaView view) => (float)view.GetValue(ShrinkProperty);
public static void SetShrink(SkiaView view, float value) => view.SetValue(ShrinkProperty, value);
public static FlexBasis GetBasis(SkiaView view) => (FlexBasis)view.GetValue(BasisProperty);
public static void SetBasis(SkiaView view, FlexBasis value) => view.SetValue(BasisProperty, value);
public static FlexAlignSelf GetAlignSelf(SkiaView view) => (FlexAlignSelf)view.GetValue(AlignSelfProperty);
public static void SetAlignSelf(SkiaView view, FlexAlignSelf value) => view.SetValue(AlignSelfProperty, value);
protected override SKSize MeasureOverride(SKSize availableSize)
{
bool isRow = Direction == FlexDirection.Row || Direction == FlexDirection.RowReverse;
float totalMain = 0f;
float maxCross = 0f;
foreach (var child in Children)
{
if (!child.IsVisible)
continue;
var childSize = child.Measure(availableSize);
if (isRow)
{
totalMain += childSize.Width;
maxCross = Math.Max(maxCross, childSize.Height);
}
else
{
totalMain += childSize.Height;
maxCross = Math.Max(maxCross, childSize.Width);
}
}
return isRow ? new SKSize(totalMain, maxCross) : new SKSize(maxCross, totalMain);
}
protected override SKRect ArrangeOverride(SKRect bounds)
{
if (Children.Count == 0)
return bounds;
bool isRow = Direction == FlexDirection.Row || Direction == FlexDirection.RowReverse;
bool isReverse = Direction == FlexDirection.RowReverse || Direction == FlexDirection.ColumnReverse;
var orderedChildren = Children.Where(c => c.IsVisible).OrderBy(c => GetOrder(c)).ToList();
if (orderedChildren.Count == 0)
return bounds;
float mainSize = isRow ? bounds.Width : bounds.Height;
float crossSize = isRow ? bounds.Height : bounds.Width;
var childInfos = new List<(SkiaView child, SKSize size, float grow, float shrink)>();
float totalBasis = 0f;
float totalGrow = 0f;
float totalShrink = 0f;
foreach (var child in orderedChildren)
{
var basis = GetBasis(child);
float grow = GetGrow(child);
float shrink = GetShrink(child);
SKSize size;
if (basis.IsAuto)
{
size = child.Measure(new SKSize(bounds.Width, bounds.Height));
}
else
{
float length = basis.Length;
size = isRow
? child.Measure(new SKSize(length, bounds.Height))
: child.Measure(new SKSize(bounds.Width, length));
}
childInfos.Add((child, size, grow, shrink));
totalBasis += isRow ? size.Width : size.Height;
totalGrow += grow;
totalShrink += shrink;
}
float freeSpace = mainSize - totalBasis;
var resolvedSizes = new List<(SkiaView child, float mainSize, float crossSize)>();
foreach (var (child, size, grow, shrink) in childInfos)
{
float childMainSize = isRow ? size.Width : size.Height;
float childCrossSize = isRow ? size.Height : size.Width;
if (freeSpace > 0f && totalGrow > 0f)
{
childMainSize += freeSpace * (grow / totalGrow);
}
else if (freeSpace < 0f && totalShrink > 0f)
{
childMainSize += freeSpace * (shrink / totalShrink);
}
resolvedSizes.Add((child, Math.Max(0f, childMainSize), childCrossSize));
}
float usedSpace = resolvedSizes.Sum(s => s.mainSize);
float remainingSpace = Math.Max(0f, mainSize - usedSpace);
float position = isRow ? bounds.Left : bounds.Top;
float spacing = 0f;
switch (JustifyContent)
{
case FlexJustify.Center:
position += remainingSpace / 2f;
break;
case FlexJustify.End:
position += remainingSpace;
break;
case FlexJustify.SpaceBetween:
if (resolvedSizes.Count > 1)
spacing = remainingSpace / (resolvedSizes.Count - 1);
break;
case FlexJustify.SpaceAround:
if (resolvedSizes.Count > 0)
{
spacing = remainingSpace / resolvedSizes.Count;
position += spacing / 2f;
}
break;
case FlexJustify.SpaceEvenly:
if (resolvedSizes.Count > 0)
{
spacing = remainingSpace / (resolvedSizes.Count + 1);
position += spacing;
}
break;
}
var items = isReverse ? resolvedSizes.AsEnumerable().Reverse() : resolvedSizes;
foreach (var (child, childMainSize, childCrossSize) in items)
{
var alignSelf = GetAlignSelf(child);
var effectiveAlign = alignSelf == FlexAlignSelf.Auto ? AlignItems : (FlexAlignItems)alignSelf;
float crossPos = isRow ? bounds.Top : bounds.Left;
float finalCrossSize = childCrossSize;
switch (effectiveAlign)
{
case FlexAlignItems.End:
crossPos = (isRow ? bounds.Bottom : bounds.Right) - finalCrossSize;
break;
case FlexAlignItems.Center:
crossPos += (crossSize - finalCrossSize) / 2f;
break;
case FlexAlignItems.Stretch:
finalCrossSize = crossSize;
break;
}
SKRect childBounds;
if (isRow)
{
childBounds = new SKRect(position, crossPos, position + childMainSize, crossPos + finalCrossSize);
}
else
{
childBounds = new SKRect(crossPos, position, crossPos + finalCrossSize, position + childMainSize);
}
child.Arrange(childBounds);
position += childMainSize + spacing;
}
return bounds;
}
}

View File

@@ -210,6 +210,25 @@ public class SkiaImage : SkiaView
}
}
/// <summary>
/// Loads the image from an SKBitmap.
/// </summary>
public void LoadFromBitmap(SKBitmap bitmap)
{
try
{
Bitmap = bitmap;
_isLoading = false;
ImageLoaded?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_isLoading = false;
ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex));
}
Invalidate();
}
protected override SKSize MeasureOverride(SKSize availableSize)
{
if (_image == null)

View File

@@ -1,6 +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 System;
using System.Collections.Generic;
using System.Linq;
using SkiaSharp;
using Microsoft.Maui.Platform.Linux.Rendering;
@@ -24,6 +27,17 @@ public class SkiaLabel : SkiaView
"",
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for FormattedSpans.
/// </summary>
public static readonly BindableProperty FormattedSpansProperty =
BindableProperty.Create(
nameof(FormattedSpans),
typeof(IList<SkiaTextSpan>),
typeof(SkiaLabel),
null,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for TextColor.
/// </summary>
@@ -191,6 +205,15 @@ public class SkiaLabel : SkiaView
set => SetValue(TextProperty, value);
}
/// <summary>
/// Gets or sets the formatted text spans for rich text rendering.
/// </summary>
public IList<SkiaTextSpan>? FormattedSpans
{
get => (IList<SkiaTextSpan>?)GetValue(FormattedSpansProperty);
set => SetValue(FormattedSpansProperty, value);
}
/// <summary>
/// Gets or sets the text color.
/// </summary>
@@ -363,6 +386,11 @@ public class SkiaLabel : SkiaView
private static SKTypeface? _cachedTypeface;
/// <summary>
/// Event raised when the label is tapped.
/// </summary>
public event EventHandler? Tapped;
private void OnTextChanged()
{
InvalidateMeasure();
@@ -400,6 +428,20 @@ public class SkiaLabel : SkiaView
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
// Calculate content bounds with padding
var contentBounds = new SKRect(
bounds.Left + Padding.Left,
bounds.Top + Padding.Top,
bounds.Right - Padding.Right,
bounds.Bottom - Padding.Bottom);
// Handle formatted spans first (rich text)
if (FormattedSpans != null && FormattedSpans.Count > 0)
{
DrawFormattedText(canvas, contentBounds);
return;
}
if (string.IsNullOrEmpty(Text))
return;
@@ -421,13 +463,6 @@ public class SkiaLabel : SkiaView
IsAntialias = true
};
// Calculate content bounds with padding
var contentBounds = new SKRect(
bounds.Left + Padding.Left,
bounds.Top + Padding.Top,
bounds.Right - Padding.Right,
bounds.Bottom - Padding.Bottom);
// Handle single line vs multiline
// Use DrawMultiLineWithWrapping when: MaxLines > 1, text has newlines, OR WordWrap is enabled
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n') ||
@@ -815,6 +850,181 @@ public class SkiaLabel : SkiaView
totalHeight + Padding.Top + Padding.Bottom);
}
}
private void DrawFormattedText(SKCanvas canvas, SKRect bounds)
{
if (FormattedSpans == null || FormattedSpans.Count == 0)
return;
float currentX = bounds.Left;
float currentY = bounds.Top;
float lineHeight = 0f;
// First pass: calculate line data
var lineSpans = new List<(SkiaTextSpan span, float x, float width, float height, SKPaint paint)>();
foreach (var span in FormattedSpans)
{
if (string.IsNullOrEmpty(span.Text))
continue;
var paint = CreateSpanPaint(span);
var textBounds = new SKRect();
paint.MeasureText(span.Text, ref textBounds);
lineHeight = Math.Max(lineHeight, textBounds.Height);
// Word wrap
if (currentX + textBounds.Width > bounds.Right && currentX > bounds.Left)
{
currentY += lineHeight * LineHeight;
currentX = bounds.Left;
lineHeight = textBounds.Height;
}
lineSpans.Add((span, currentX, textBounds.Width, textBounds.Height, paint));
currentX += textBounds.Width;
}
// Calculate vertical offset
float totalHeight = currentY + lineHeight - bounds.Top;
float verticalOffset = VerticalTextAlignment switch
{
TextAlignment.Start => 0f,
TextAlignment.Center => (bounds.Height - totalHeight) / 2f,
TextAlignment.End => bounds.Height - totalHeight,
_ => 0f
};
// Second pass: draw with alignment
currentX = bounds.Left;
currentY = bounds.Top + verticalOffset;
lineHeight = 0f;
var currentLine = new List<(SkiaTextSpan span, float relX, float width, float height, SKPaint paint)>();
float lineLeft = bounds.Left;
foreach (var span in FormattedSpans)
{
if (string.IsNullOrEmpty(span.Text))
continue;
var paint = CreateSpanPaint(span);
var textBounds = new SKRect();
paint.MeasureText(span.Text, ref textBounds);
lineHeight = Math.Max(lineHeight, textBounds.Height);
if (currentX + textBounds.Width > bounds.Right && currentX > bounds.Left)
{
DrawFormattedLine(canvas, bounds, currentLine, currentY + lineHeight);
currentY += lineHeight * LineHeight;
currentX = bounds.Left;
lineHeight = textBounds.Height;
currentLine.Clear();
}
currentLine.Add((span, currentX - lineLeft, textBounds.Width, textBounds.Height, paint));
currentX += textBounds.Width;
}
if (currentLine.Count > 0)
{
DrawFormattedLine(canvas, bounds, currentLine, currentY + lineHeight);
}
}
private void DrawFormattedLine(SKCanvas canvas, SKRect bounds,
List<(SkiaTextSpan span, float x, float width, float height, SKPaint paint)> lineSpans, float y)
{
if (lineSpans.Count == 0) return;
float lineWidth = lineSpans.Sum(s => s.width);
float startX = HorizontalTextAlignment switch
{
TextAlignment.Start => bounds.Left,
TextAlignment.Center => bounds.Left + (bounds.Width - lineWidth) / 2f,
TextAlignment.End => bounds.Right - lineWidth,
_ => bounds.Left
};
foreach (var (span, relX, width, height, paint) in lineSpans)
{
float x = startX + relX;
// Draw background if specified
if (span.BackgroundColor.HasValue && span.BackgroundColor.Value != SKColors.Transparent)
{
using var bgPaint = new SKPaint
{
Color = span.BackgroundColor.Value,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(x, y - height, width, height + 4f, bgPaint);
}
canvas.DrawText(span.Text, x, y, paint);
// Draw underline
if (span.IsUnderline)
{
using var linePaint = new SKPaint
{
Color = paint.Color,
StrokeWidth = 1f,
IsAntialias = true
};
canvas.DrawLine(x, y + 2f, x + width, y + 2f, linePaint);
}
// Draw strikethrough
if (span.IsStrikethrough)
{
using var linePaint = new SKPaint
{
Color = paint.Color,
StrokeWidth = 1f,
IsAntialias = true
};
canvas.DrawLine(x, y - height / 3f, x + width, y - height / 3f, linePaint);
}
paint.Dispose();
}
}
private SKPaint CreateSpanPaint(SkiaTextSpan span)
{
var fontStyle = new SKFontStyle(
span.IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
span.IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(span.FontFamily ?? FontFamily, fontStyle);
if (typeface == null || typeface == SKTypeface.Default)
{
typeface = GetLinuxTypeface();
}
var fontSize = span.FontSize > 0f ? span.FontSize : FontSize;
using var font = new SKFont(typeface, fontSize);
var color = span.TextColor ?? TextColor;
if (!IsEnabled)
{
color = color.WithAlpha(128);
}
return new SKPaint(font)
{
Color = color,
IsAntialias = true
};
}
public override void OnPointerPressed(PointerEventArgs e)
{
base.OnPointerPressed(e);
Tapped?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>

View File

@@ -284,6 +284,21 @@ public class SkiaShell : SkiaLayoutView
/// </summary>
public int CurrentSectionIndex => _selectedSectionIndex;
/// <summary>
/// Reference to the MAUI Shell this view represents.
/// </summary>
public Shell? MauiShell { get; set; }
/// <summary>
/// Callback to render content from a ShellContent.
/// </summary>
public Func<Microsoft.Maui.Controls.ShellContent, SkiaView?>? ContentRenderer { get; set; }
/// <summary>
/// Callback to refresh shell colors.
/// </summary>
public Action<SkiaShell, Shell>? ColorRefresher { get; set; }
/// <summary>
/// Event raised when FlyoutIsPresented changes.
/// </summary>
@@ -342,6 +357,48 @@ public class SkiaShell : SkiaLayoutView
Invalidate();
}
/// <summary>
/// Refreshes the shell theme and re-renders all pages.
/// </summary>
public void RefreshTheme()
{
Console.WriteLine("[SkiaShell] RefreshTheme called - refreshing all pages");
if (MauiShell != null && ColorRefresher != null)
{
Console.WriteLine("[SkiaShell] Refreshing shell colors");
ColorRefresher(this, MauiShell);
}
if (ContentRenderer != null)
{
foreach (var section in _sections)
{
foreach (var item in section.Items)
{
if (item.MauiShellContent != null)
{
Console.WriteLine("[SkiaShell] Re-rendering: " + item.Title);
var skiaView = ContentRenderer(item.MauiShellContent);
if (skiaView != null)
{
item.Content = skiaView;
}
}
}
}
}
if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count)
{
var section = _sections[_selectedSectionIndex];
if (_selectedItemIndex >= 0 && _selectedItemIndex < section.Items.Count)
{
var item = section.Items[_selectedItemIndex];
SetCurrentContent(item.Content);
}
}
InvalidateMeasure();
Invalidate();
}
/// <summary>
/// Navigates using a URI route.
/// </summary>
@@ -900,6 +957,11 @@ public class ShellContent
/// The content view.
/// </summary>
public SkiaView? Content { get; set; }
/// <summary>
/// Reference to the MAUI ShellContent this represents.
/// </summary>
public Microsoft.Maui.Controls.ShellContent? MauiShellContent { get; set; }
}
/// <summary>

View File

@@ -1,6 +1,11 @@
// 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.Linux;
using Microsoft.Maui.Platform.Linux.Handlers;
using Microsoft.Maui.Platform.Linux.Rendering;
using Microsoft.Maui.Platform.Linux.Window;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
@@ -218,6 +223,116 @@ public abstract class SkiaView : BindableObject, IDisposable
typeof(SkiaView),
string.Empty);
/// <summary>
/// Bindable property for Scale.
/// </summary>
public static readonly BindableProperty ScaleProperty =
BindableProperty.Create(
nameof(Scale),
typeof(double),
typeof(SkiaView),
1.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for ScaleX.
/// </summary>
public static readonly BindableProperty ScaleXProperty =
BindableProperty.Create(
nameof(ScaleX),
typeof(double),
typeof(SkiaView),
1.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for ScaleY.
/// </summary>
public static readonly BindableProperty ScaleYProperty =
BindableProperty.Create(
nameof(ScaleY),
typeof(double),
typeof(SkiaView),
1.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for Rotation.
/// </summary>
public static readonly BindableProperty RotationProperty =
BindableProperty.Create(
nameof(Rotation),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for RotationX.
/// </summary>
public static readonly BindableProperty RotationXProperty =
BindableProperty.Create(
nameof(RotationX),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for RotationY.
/// </summary>
public static readonly BindableProperty RotationYProperty =
BindableProperty.Create(
nameof(RotationY),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for TranslationX.
/// </summary>
public static readonly BindableProperty TranslationXProperty =
BindableProperty.Create(
nameof(TranslationX),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for TranslationY.
/// </summary>
public static readonly BindableProperty TranslationYProperty =
BindableProperty.Create(
nameof(TranslationY),
typeof(double),
typeof(SkiaView),
0.0,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for AnchorX.
/// </summary>
public static readonly BindableProperty AnchorXProperty =
BindableProperty.Create(
nameof(AnchorX),
typeof(double),
typeof(SkiaView),
0.5,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
/// <summary>
/// Bindable property for AnchorY.
/// </summary>
public static readonly BindableProperty AnchorYProperty =
BindableProperty.Create(
nameof(AnchorY),
typeof(double),
typeof(SkiaView),
0.5,
propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate());
#endregion
private bool _disposed;
@@ -408,6 +523,107 @@ public abstract class SkiaView : BindableObject, IDisposable
set => SetValue(NameProperty, value);
}
/// <summary>
/// Gets or sets the uniform scale factor.
/// </summary>
public double Scale
{
get => (double)GetValue(ScaleProperty);
set => SetValue(ScaleProperty, value);
}
/// <summary>
/// Gets or sets the X-axis scale factor.
/// </summary>
public double ScaleX
{
get => (double)GetValue(ScaleXProperty);
set => SetValue(ScaleXProperty, value);
}
/// <summary>
/// Gets or sets the Y-axis scale factor.
/// </summary>
public double ScaleY
{
get => (double)GetValue(ScaleYProperty);
set => SetValue(ScaleYProperty, value);
}
/// <summary>
/// Gets or sets the rotation in degrees around the Z-axis.
/// </summary>
public double Rotation
{
get => (double)GetValue(RotationProperty);
set => SetValue(RotationProperty, value);
}
/// <summary>
/// Gets or sets the rotation in degrees around the X-axis.
/// </summary>
public double RotationX
{
get => (double)GetValue(RotationXProperty);
set => SetValue(RotationXProperty, value);
}
/// <summary>
/// Gets or sets the rotation in degrees around the Y-axis.
/// </summary>
public double RotationY
{
get => (double)GetValue(RotationYProperty);
set => SetValue(RotationYProperty, value);
}
/// <summary>
/// Gets or sets the X translation offset.
/// </summary>
public double TranslationX
{
get => (double)GetValue(TranslationXProperty);
set => SetValue(TranslationXProperty, value);
}
/// <summary>
/// Gets or sets the Y translation offset.
/// </summary>
public double TranslationY
{
get => (double)GetValue(TranslationYProperty);
set => SetValue(TranslationYProperty, value);
}
/// <summary>
/// Gets or sets the X anchor point for transforms (0.0 to 1.0).
/// </summary>
public double AnchorX
{
get => (double)GetValue(AnchorXProperty);
set => SetValue(AnchorXProperty, value);
}
/// <summary>
/// Gets or sets the Y anchor point for transforms (0.0 to 1.0).
/// </summary>
public double AnchorY
{
get => (double)GetValue(AnchorYProperty);
set => SetValue(AnchorYProperty, value);
}
/// <summary>
/// Gets or sets the cursor type when hovering over this view.
/// </summary>
public CursorType CursorType { get; set; }
/// <summary>
/// Gets or sets the MAUI View this platform view represents.
/// Used for gesture processing.
/// </summary>
public View? MauiView { get; set; }
/// <summary>
/// Gets or sets whether this view currently has keyboard focus.
/// </summary>
@@ -566,8 +782,23 @@ public abstract class SkiaView : BindableObject, IDisposable
/// </summary>
public void Invalidate()
{
LinuxApplication.LogInvalidate(GetType().Name);
Invalidated?.Invoke(this, EventArgs.Empty);
_parent?.Invalidate();
// Notify rendering engine of dirty region
if (Bounds.Width > 0 && Bounds.Height > 0)
{
SkiaRenderingEngine.Current?.InvalidateRegion(Bounds);
}
if (_parent != null)
{
_parent.Invalidate();
}
else
{
LinuxApplication.RequestRedraw();
}
}
/// <summary>
@@ -583,7 +814,7 @@ public abstract class SkiaView : BindableObject, IDisposable
/// <summary>
/// Draws this view and its children to the canvas.
/// </summary>
public void Draw(SKCanvas canvas)
public virtual void Draw(SKCanvas canvas)
{
if (!IsVisible || Opacity <= 0)
{
@@ -592,6 +823,42 @@ public abstract class SkiaView : BindableObject, IDisposable
canvas.Save();
// Apply transforms if any are set
if (Scale != 1.0 || ScaleX != 1.0 || ScaleY != 1.0 ||
Rotation != 0.0 || RotationX != 0.0 || RotationY != 0.0 ||
TranslationX != 0.0 || TranslationY != 0.0)
{
// Calculate anchor point in absolute coordinates
float anchorAbsX = Bounds.Left + (float)(Bounds.Width * AnchorX);
float anchorAbsY = Bounds.Top + (float)(Bounds.Height * AnchorY);
// Move origin to anchor point
canvas.Translate(anchorAbsX, anchorAbsY);
// Apply translation
if (TranslationX != 0.0 || TranslationY != 0.0)
{
canvas.Translate((float)TranslationX, (float)TranslationY);
}
// Apply rotation
if (Rotation != 0.0)
{
canvas.RotateDegrees((float)Rotation);
}
// Apply scale
float scaleX = (float)(Scale * ScaleX);
float scaleY = (float)(Scale * ScaleY);
if (scaleX != 1f || scaleY != 1f)
{
canvas.Scale(scaleX, scaleY);
}
// Move origin back
canvas.Translate(-anchorAbsX, -anchorAbsY);
}
// Apply opacity
if (Opacity < 1.0f)
{
@@ -706,11 +973,47 @@ public abstract class SkiaView : BindableObject, IDisposable
#region Input Events
public virtual void OnPointerEntered(PointerEventArgs e) { }
public virtual void OnPointerExited(PointerEventArgs e) { }
public virtual void OnPointerMoved(PointerEventArgs e) { }
public virtual void OnPointerPressed(PointerEventArgs e) { }
public virtual void OnPointerReleased(PointerEventArgs e) { }
public virtual void OnPointerEntered(PointerEventArgs e)
{
if (MauiView != null)
{
GestureManager.ProcessPointerEntered(MauiView, e.X, e.Y);
}
}
public virtual void OnPointerExited(PointerEventArgs e)
{
if (MauiView != null)
{
GestureManager.ProcessPointerExited(MauiView, e.X, e.Y);
}
}
public virtual void OnPointerMoved(PointerEventArgs e)
{
if (MauiView != null)
{
GestureManager.ProcessPointerMove(MauiView, e.X, e.Y);
}
}
public virtual void OnPointerPressed(PointerEventArgs e)
{
if (MauiView != null)
{
GestureManager.ProcessPointerDown(MauiView, e.X, e.Y);
}
}
public virtual void OnPointerReleased(PointerEventArgs e)
{
Console.WriteLine($"[SkiaView] OnPointerReleased on {GetType().Name}, MauiView={MauiView?.GetType().Name ?? "null"}");
if (MauiView != null)
{
GestureManager.ProcessPointerUp(MauiView, e.X, e.Y);
}
}
public virtual void OnScroll(ScrollEventArgs e) { }
public virtual void OnKeyDown(KeyEventArgs e) { }
public virtual void OnKeyUp(KeyEventArgs e) { }

View File

@@ -129,6 +129,37 @@ public class SkiaWebView : SkiaView
private const int RTLD_GLOBAL = 0x100;
private static IntPtr _webkitHandle;
private static IntPtr _mainDisplay;
private static IntPtr _mainWindow;
private static readonly HashSet<SkiaWebView> _activeWebViews = new();
/// <summary>
/// Sets the main window for WebView operations.
/// </summary>
public static void SetMainWindow(IntPtr display, IntPtr window)
{
_mainDisplay = display;
_mainWindow = window;
Console.WriteLine($"[WebView] Main window set: display={display}, window={window}");
}
/// <summary>
/// Processes pending GTK events for WebViews.
/// </summary>
public static void ProcessGtkEvents()
{
bool hasActiveWebViews;
lock (_activeWebViews)
{
hasActiveWebViews = _activeWebViews.Count > 0;
}
if (hasActiveWebViews && _gtkInitialized)
{
while (g_main_context_iteration(IntPtr.Zero, mayBlock: false))
{
}
}
}
#endregion