refactor: split large files into partial classes by concern

Split LinuxApplication into Input and Lifecycle partials. Extract SkiaView into Accessibility, Drawing, and Input partials. Split SkiaEntry and SkiaEditor into Drawing and Input partials. Extract TextRenderingHelper from SkiaRenderingEngine. Create dedicated files for SkiaAbsoluteLayout, SkiaGrid, and SkiaStackLayout. This reduces file sizes from 40K+ lines to manageable units organized by responsibility.
This commit is contained in:
2026-03-06 22:36:23 -05:00
parent ee60b983a4
commit 077abc2feb
20 changed files with 4693 additions and 4577 deletions

160
Views/SkiaAbsoluteLayout.cs Normal file
View File

@@ -0,0 +1,160 @@
// 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.Graphics;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
using Microsoft.Maui;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Absolute layout that positions children at exact coordinates.
/// </summary>
public class SkiaAbsoluteLayout : SkiaLayoutView
{
private readonly Dictionary<SkiaView, AbsoluteLayoutBounds> _childBounds = new();
/// <summary>
/// Adds a child at the specified position and size.
/// </summary>
public void AddChild(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
{
base.AddChild(child);
_childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
}
public override void RemoveChild(SkiaView child)
{
base.RemoveChild(child);
_childBounds.Remove(child);
}
/// <summary>
/// Gets the layout bounds for a child.
/// </summary>
public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
{
return _childBounds.TryGetValue(child, out var bounds)
? bounds
: new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
}
/// <summary>
/// Sets the layout bounds for a child.
/// </summary>
public void SetLayoutBounds(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
{
_childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
InvalidateMeasure();
Invalidate();
}
protected override Size MeasureOverride(Size availableSize)
{
float maxRight = 0;
float maxBottom = 0;
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var layout = GetLayoutBounds(child);
var bounds = layout.Bounds;
child.Measure(new Size(bounds.Width, bounds.Height));
maxRight = Math.Max(maxRight, bounds.Right);
maxBottom = Math.Max(maxBottom, bounds.Bottom);
}
return new Size(
maxRight + Padding.Left + Padding.Right,
maxBottom + Padding.Top + Padding.Bottom);
}
protected override Rect ArrangeOverride(Rect bounds)
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var layout = GetLayoutBounds(child);
var childBounds = layout.Bounds;
var flags = layout.Flags;
float x, y, width, height;
// X position
if (flags.HasFlag(AbsoluteLayoutFlags.XProportional))
x = content.Left + childBounds.Left * content.Width;
else
x = content.Left + childBounds.Left;
// Y position
if (flags.HasFlag(AbsoluteLayoutFlags.YProportional))
y = content.Top + childBounds.Top * content.Height;
else
y = content.Top + childBounds.Top;
// Width
if (flags.HasFlag(AbsoluteLayoutFlags.WidthProportional))
width = childBounds.Width * content.Width;
else if (childBounds.Width < 0)
width = (float)child.DesiredSize.Width;
else
width = childBounds.Width;
// Height
if (flags.HasFlag(AbsoluteLayoutFlags.HeightProportional))
height = childBounds.Height * content.Height;
else if (childBounds.Height < 0)
height = (float)child.DesiredSize.Height;
else
height = childBounds.Height;
// Apply child's margin
var margin = child.Margin;
var marginedBounds = new Rect(
x + (float)margin.Left,
y + (float)margin.Top,
width - (float)margin.Left - (float)margin.Right,
height - (float)margin.Top - (float)margin.Bottom);
child.Arrange(marginedBounds);
}
return bounds;
}
}
/// <summary>
/// Absolute layout bounds for a child.
/// </summary>
public readonly struct AbsoluteLayoutBounds
{
public SKRect Bounds { get; }
public AbsoluteLayoutFlags Flags { get; }
public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
{
Bounds = bounds;
Flags = flags;
}
}
/// <summary>
/// Flags for absolute layout positioning.
/// </summary>
[Flags]
public enum AbsoluteLayoutFlags
{
None = 0,
XProportional = 1,
YProportional = 2,
WidthProportional = 4,
HeightProportional = 8,
PositionProportional = XProportional | YProportional,
SizeProportional = WidthProportional | HeightProportional,
All = XProportional | YProportional | WidthProportional | HeightProportional
}

255
Views/SkiaEditor.Drawing.cs Normal file
View File

@@ -0,0 +1,255 @@
// 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 Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform.Linux.Rendering;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public partial class SkiaEditor
{
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
var paddingLeft = (float)Padding.Left;
var paddingTop = (float)Padding.Top;
var paddingRight = (float)Padding.Right;
var paddingBottom = (float)Padding.Bottom;
var fontSize = (float)FontSize;
var lineHeight = (float)LineHeight;
var cornerRadius = (float)CornerRadius;
// Update wrap width if bounds changed and re-wrap text
var newWrapWidth = bounds.Width - paddingLeft - paddingRight;
if (Math.Abs(newWrapWidth - _wrapWidth) > 1)
{
_wrapWidth = newWrapWidth;
UpdateLines();
}
// Draw background
var bgColor = EditorBackgroundColor != null ? ToSKColor(EditorBackgroundColor) :
(IsEnabled ? SkiaTheme.BackgroundWhiteSK : SkiaTheme.Gray100SK);
using var bgPaint = new SKPaint
{
Color = bgColor,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), bgPaint);
// Draw border only if BorderColor is not transparent
if (BorderColor != null && BorderColor != Colors.Transparent && BorderColor.Alpha > 0)
{
using var borderPaint = new SKPaint
{
Color = IsFocused ? ToSKColor(CursorColor) : ToSKColor(BorderColor),
Style = SKPaintStyle.Stroke,
StrokeWidth = IsFocused ? 2 : 1,
IsAntialias = true
};
canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), borderPaint);
}
// Setup text rendering
using var font = new SKFont(SKTypeface.Default, fontSize);
var lineSpacing = fontSize * lineHeight;
// Clip to content area
var contentRect = new SKRect(
bounds.Left + paddingLeft,
bounds.Top + paddingTop,
bounds.Right - paddingRight,
bounds.Bottom - paddingBottom);
canvas.Save();
canvas.ClipRect(contentRect);
// Don't translate - let the text draw at absolute positions
// canvas.Translate(0, -_scrollOffsetY);
if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder))
{
using var placeholderPaint = new SKPaint(font)
{
Color = GetEffectivePlaceholderColor(),
IsAntialias = true
};
// Handle multiline placeholder text by splitting on newlines
var placeholderLines = Placeholder.Split('\n');
var y = contentRect.Top + fontSize;
foreach (var line in placeholderLines)
{
canvas.DrawText(line, contentRect.Left, y, placeholderPaint);
y += lineSpacing;
}
}
else
{
var textColor = GetEffectiveTextColor();
using var textPaint = new SKPaint(font)
{
Color = IsEnabled ? textColor : textColor.WithAlpha(128),
IsAntialias = true
};
using var selectionPaint = new SKPaint
{
Color = ToSKColor(SelectionColor),
Style = SKPaintStyle.Fill
};
var y = contentRect.Top + fontSize;
var charIndex = 0;
for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++)
{
var line = _lines[lineIndex];
var x = contentRect.Left;
// Draw selection for this line if applicable
if (_selectionStart >= 0 && _selectionLength != 0)
{
// 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 && selStart < lineEnd)
{
var selStartInLine = Math.Max(0, selStart - lineStart);
var selEndInLine = Math.Min(line.Length, selEnd - lineStart);
var startX = x + MeasureText(line.Substring(0, selStartInLine), font);
var endX = x + MeasureText(line.Substring(0, selEndInLine), font);
canvas.DrawRect(new SKRect(startX, y - fontSize, endX, y + lineSpacing - fontSize), selectionPaint);
}
}
// Determine if pre-edit text should be displayed on this line
var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition);
var displayLine = line;
var hasPreEditOnThisLine = !string.IsNullOrEmpty(_preEditText) && cursorLine == lineIndex;
if (hasPreEditOnThisLine)
{
// Insert pre-edit text at cursor position within this line
var insertPos = Math.Min(cursorCol, line.Length);
displayLine = line.Insert(insertPos, _preEditText);
}
// Draw the text with font fallback for emoji/CJK support
DrawTextWithFallback(canvas, displayLine, x, y, textPaint, SKTypeface.Default);
// Draw underline for pre-edit (composition) text
if (hasPreEditOnThisLine)
{
DrawPreEditUnderline(canvas, textPaint, line, x, y, contentRect);
}
// Draw cursor if on this line
if (IsFocused && _cursorVisible)
{
if (cursorLine == lineIndex)
{
// Account for pre-edit text when calculating cursor position
var textToCursor = line.Substring(0, Math.Min(cursorCol, line.Length));
var cursorX = x + MeasureText(textToCursor, font);
// If there's pre-edit text, cursor goes after it
if (hasPreEditOnThisLine && _preEditText.Length > 0)
{
cursorX += MeasureText(_preEditText, font);
}
using var cursorPaint = new SKPaint
{
Color = ToSKColor(CursorColor),
Style = SKPaintStyle.Stroke,
StrokeWidth = 2,
IsAntialias = true
};
canvas.DrawLine(cursorX, y - fontSize + 2, cursorX, y + 2, cursorPaint);
}
}
y += lineSpacing;
charIndex += line.Length + 1;
}
}
canvas.Restore();
// Draw scrollbar if needed
var totalHeight = _lines.Count * fontSize * lineHeight;
if (totalHeight > contentRect.Height)
{
DrawScrollbar(canvas, bounds, contentRect.Height, totalHeight);
}
}
private float MeasureText(string text, SKFont font)
{
if (string.IsNullOrEmpty(text)) return 0;
using var paint = new SKPaint(font);
return paint.MeasureText(text);
}
private void DrawScrollbar(SKCanvas canvas, SKRect bounds, float viewHeight, float contentHeight)
{
var scrollbarWidth = 6f;
var scrollbarMargin = 2f;
var paddingTop = (float)Padding.Top;
var scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight));
var scrollbarY = bounds.Top + paddingTop + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight);
using var paint = new SKPaint
{
Color = SkiaTheme.Shadow25SK,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(new SKRoundRect(
new SKRect(
bounds.Right - scrollbarWidth - scrollbarMargin,
scrollbarY,
bounds.Right - scrollbarMargin,
scrollbarY + scrollbarHeight),
scrollbarWidth / 2), paint);
}
/// <summary>
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
=> TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, (float)FontSize);
/// <summary>
/// Draws underline for IME pre-edit (composition) text.
/// </summary>
private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
=> TextRenderingHelper.DrawPreEditUnderline(canvas, paint, displayText, _cursorPosition, _preEditText, x, y);
protected override Size MeasureOverride(Size availableSize)
{
if (AutoSize)
{
var fontSize = (float)FontSize;
var lineHeight = (float)LineHeight;
var lineSpacing = fontSize * lineHeight;
var verticalPadding = Padding.Top + Padding.Bottom;
var height = Math.Max(lineSpacing + verticalPadding, _lines.Count * lineSpacing + verticalPadding);
return new Size(
availableSize.Width < double.MaxValue ? availableSize.Width : 200,
Math.Min(height, availableSize.Height < double.MaxValue ? availableSize.Height : 200));
}
return new Size(
availableSize.Width < double.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
availableSize.Height < double.MaxValue ? Math.Min(availableSize.Height, 150) : 150);
}
}

756
Views/SkiaEditor.Input.cs Normal file
View File

@@ -0,0 +1,756 @@
// 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 Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform.Linux;
using Microsoft.Maui.Platform.Linux.Rendering;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public partial class SkiaEditor
{
#region IInputContext Implementation
/// <summary>
/// Gets or sets the text for IME context.
/// </summary>
string IInputContext.Text
{
get => Text;
set => Text = value;
}
/// <summary>
/// Gets or sets the cursor position for IME context.
/// </summary>
int IInputContext.CursorPosition
{
get => _cursorPosition;
set => CursorPosition = value;
}
/// <summary>
/// Gets the selection start for IME context.
/// </summary>
int IInputContext.SelectionStart => _selectionStart;
/// <summary>
/// Gets the selection length for IME context.
/// </summary>
int IInputContext.SelectionLength => _selectionLength;
/// <summary>
/// Called when IME commits text.
/// </summary>
public void OnTextCommitted(string text)
{
if (IsReadOnly) return;
// Delete selection if any
if (_selectionLength != 0)
{
DeleteSelection();
}
// Clear pre-edit text
_preEditText = string.Empty;
_preEditCursorPosition = 0;
// Check max length
if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
{
text = text.Substring(0, MaxLength - Text.Length);
}
// Insert committed text at cursor
var newText = Text.Insert(_cursorPosition, text);
var newPos = _cursorPosition + text.Length;
Text = newText;
_cursorPosition = newPos;
EnsureCursorVisible();
Invalidate();
}
/// <summary>
/// Called when IME pre-edit (composition) text changes.
/// </summary>
public void OnPreEditChanged(string preEditText, int cursorPosition)
{
_preEditText = preEditText ?? string.Empty;
_preEditCursorPosition = cursorPosition;
Invalidate();
}
/// <summary>
/// Called when IME pre-edit ends (cancelled or committed).
/// </summary>
public void OnPreEditEnded()
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
Invalidate();
}
#endregion
private void UpdateLines()
{
_lines.Clear();
var text = Text ?? "";
if (string.IsNullOrEmpty(text))
{
_lines.Add("");
return;
}
using var font = new SKFont(SKTypeface.Default, (float)FontSize);
// Split by actual newlines first
var paragraphs = text.Split('\n');
foreach (var paragraph in paragraphs)
{
if (string.IsNullOrEmpty(paragraph))
{
_lines.Add("");
continue;
}
// Word wrap this paragraph if we have a known width
if (_wrapWidth > 0)
{
WrapParagraph(paragraph, font, _wrapWidth);
}
else
{
_lines.Add(paragraph);
}
}
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)
{
var pos = 0;
for (int i = 0; i < _lines.Count; i++)
{
var lineLength = _lines[i].Length;
if (pos + lineLength >= position || i == _lines.Count - 1)
{
return (i, position - pos);
}
pos += lineLength + 1;
}
return (_lines.Count - 1, _lines[^1].Length);
}
private int GetPosition(int line, int column)
{
var pos = 0;
for (int i = 0; i < line && i < _lines.Count; i++)
{
pos += _lines[i].Length + 1;
}
if (line < _lines.Count)
{
pos += Math.Min(column, _lines[line].Length);
}
return Math.Min(pos, Text.Length);
}
private void EnsureCursorVisible()
{
var (line, col) = GetLineColumn(_cursorPosition);
var fontSize = (float)FontSize;
var lineHeight = (float)LineHeight;
var lineSpacing = fontSize * lineHeight;
var cursorY = line * lineSpacing;
var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom);
if (cursorY < _scrollOffsetY)
{
_scrollOffsetY = cursorY;
}
else if (cursorY + lineSpacing > _scrollOffsetY + (float)viewHeight)
{
_scrollOffsetY = cursorY + lineSpacing - (float)viewHeight;
}
}
public override void OnPointerPressed(PointerEventArgs e)
{
DiagnosticLog.Debug("SkiaEditor", $"OnPointerPressed: Button={e.Button}, IsEnabled={IsEnabled}");
if (!IsEnabled) return;
// Handle right-click context menu
if (e.Button == PointerButton.Right)
{
DiagnosticLog.Debug("SkiaEditor", "Right-click detected, showing context menu");
ShowContextMenu(e.X, e.Y);
return;
}
IsFocused = true;
// Use screen coordinates for proper hit detection
var screenBounds = ScreenBounds;
var paddingLeft = (float)Padding.Left;
var paddingTop = (float)Padding.Top;
var contentX = e.X - screenBounds.Left - paddingLeft;
var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
var fontSize = (float)FontSize;
var lineSpacing = fontSize * (float)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;
}
_cursorPosition = GetPosition(clickedLine, clickedCol);
// 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 paddingLeft = (float)Padding.Left;
var paddingTop = (float)Padding.Top;
var contentX = e.X - screenBounds.Left - paddingLeft;
var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
var fontSize = (float)FontSize;
var lineSpacing = fontSize * (float)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;
var (line, col) = GetLineColumn(_cursorPosition);
_cursorVisible = true;
_lastCursorBlink = DateTime.Now;
switch (e.Key)
{
case Key.Left:
if (_cursorPosition > 0)
{
_cursorPosition--;
EnsureCursorVisible();
}
e.Handled = true;
break;
case Key.Right:
if (_cursorPosition < Text.Length)
{
_cursorPosition++;
EnsureCursorVisible();
}
e.Handled = true;
break;
case Key.Up:
if (line > 0)
{
_cursorPosition = GetPosition(line - 1, col);
EnsureCursorVisible();
}
e.Handled = true;
break;
case Key.Down:
if (line < _lines.Count - 1)
{
_cursorPosition = GetPosition(line + 1, col);
EnsureCursorVisible();
}
e.Handled = true;
break;
case Key.Home:
_cursorPosition = GetPosition(line, 0);
EnsureCursorVisible();
e.Handled = true;
break;
case Key.End:
_cursorPosition = GetPosition(line, _lines[line].Length);
EnsureCursorVisible();
e.Handled = true;
break;
case Key.Enter:
if (!IsReadOnly)
{
InsertText("\n");
}
e.Handled = true;
break;
case Key.Backspace:
if (!IsReadOnly)
{
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)
{
if (_selectionLength != 0)
{
DeleteSelection();
}
else if (_cursorPosition < Text.Length)
{
Text = Text.Remove(_cursorPosition, 1);
}
}
e.Handled = true;
break;
case Key.Tab:
if (!IsReadOnly)
{
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();
}
public override void OnTextInput(TextInputEventArgs e)
{
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))
{
InsertText(e.Text);
e.Handled = true;
}
}
private void InsertText(string text)
{
if (_selectionLength > 0)
{
var currentText = Text;
Text = currentText.Remove(_selectionStart, _selectionLength);
_cursorPosition = _selectionStart;
_selectionStart = -1;
_selectionLength = 0;
}
if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
{
text = text.Substring(0, Math.Max(0, MaxLength - Text.Length));
}
if (!string.IsNullOrEmpty(text))
{
Text = Text.Insert(_cursorPosition, text);
_cursorPosition += text.Length;
EnsureCursorVisible();
}
}
public override void OnScroll(ScrollEventArgs e)
{
var fontSize = (float)FontSize;
var lineHeight = (float)LineHeight;
var lineSpacing = fontSize * lineHeight;
var totalHeight = _lines.Count * lineSpacing;
var viewHeight = (float)Bounds.Height - (float)(Padding.Top + Padding.Bottom);
var maxScroll = Math.Max(0, totalHeight - viewHeight);
_scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll);
Invalidate();
}
public override void OnFocusGained()
{
base.OnFocusGained();
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
// Connect to IME service
_inputMethodService?.SetFocus(this);
// Update cursor location for IME candidate window positioning
UpdateImeCursorLocation();
}
public override void OnFocusLost()
{
base.OnFocusLost();
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
// Disconnect from IME service and reset any composition
_inputMethodService?.SetFocus(null);
_preEditText = string.Empty;
_preEditCursorPosition = 0;
Completed?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Resets the cursor blink timer (shows cursor immediately).
/// </summary>
private void ResetCursorBlink()
{
_lastCursorBlink = DateTime.Now;
_cursorVisible = true;
}
/// <summary>
/// Updates cursor blink animation. Called by the application's animation loop.
/// </summary>
public void UpdateCursorBlink()
{
if (!IsFocused) return;
var elapsed = (DateTime.Now - _lastCursorBlink).TotalMilliseconds;
var newVisible = ((int)(elapsed / 500) % 2) == 0;
if (newVisible != _cursorVisible)
{
_cursorVisible = newVisible;
Invalidate();
}
}
#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;
}
private void ShowContextMenu(float x, float y)
{
DiagnosticLog.Debug("SkiaEditor", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
bool hasSelection = _selectionLength != 0;
bool hasText = !string.IsNullOrEmpty(Text);
bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
bool isEditable = !IsReadOnly;
if (LinuxApplication.IsGtkMode)
{
// Use GTK context menu when running in GTK mode (e.g., with WebView)
GtkContextMenuService.ShowContextMenu(new List<GtkMenuItem>
{
new GtkMenuItem("Cut", () =>
{
CutToClipboard();
Invalidate();
}, hasSelection && isEditable),
new GtkMenuItem("Copy", () =>
{
CopyToClipboard();
}, hasSelection),
new GtkMenuItem("Paste", () =>
{
PasteFromClipboard();
Invalidate();
}, hasClipboard && isEditable),
GtkMenuItem.Separator,
new GtkMenuItem("Select All", () =>
{
SelectAll();
Invalidate();
}, hasText)
});
}
else
{
// Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
var items = new List<ContextMenuItem>
{
new ContextMenuItem("Cut", () =>
{
CutToClipboard();
Invalidate();
}, hasSelection && isEditable),
new ContextMenuItem("Copy", () =>
{
CopyToClipboard();
}, hasSelection),
new ContextMenuItem("Paste", () =>
{
PasteFromClipboard();
Invalidate();
}, hasClipboard && isEditable),
ContextMenuItem.Separator,
new ContextMenuItem("Select All", () =>
{
SelectAll();
Invalidate();
}, hasText)
};
var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
LinuxDialogService.ShowContextMenu(menu);
}
}
#endregion
/// <summary>
/// Updates the IME cursor location for candidate window positioning.
/// </summary>
private void UpdateImeCursorLocation()
{
if (_inputMethodService == null) return;
var screenBounds = ScreenBounds;
var fontSize = (float)FontSize;
var lineSpacing = fontSize * (float)LineHeight;
var (line, col) = GetLineColumn(_cursorPosition);
using var font = new SKFont(SKTypeface.Default, fontSize);
var lineText = line < _lines.Count ? _lines[line] : "";
var textToCursor = lineText.Substring(0, Math.Min(col, lineText.Length));
var cursorX = MeasureText(textToCursor, font);
int x = (int)(screenBounds.Left + Padding.Left + cursorX);
int y = (int)(screenBounds.Top + Padding.Top + line * lineSpacing - _scrollOffsetY);
int height = (int)fontSize;
_inputMethodService.SetCursorLocation(x, y, 2, height);
}
}

View File

File diff suppressed because it is too large Load Diff

303
Views/SkiaEntry.Drawing.cs Normal file
View File

@@ -0,0 +1,303 @@
// 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 Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform.Linux.Rendering;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public partial class SkiaEntry
{
protected override void DrawBackground(SKCanvas canvas, SKRect bounds)
{
// Skip base background drawing if Entry is transparent
// (transparent Entry is likely inside a Border that handles appearance)
var bgColor = ToSKColor(EntryBackgroundColor);
var baseBgColor = GetEffectiveBackgroundColor();
if (bgColor.Alpha < 10 && baseBgColor.Alpha < 10)
return;
// Otherwise let base class draw
base.DrawBackground(canvas, bounds);
}
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
var bgColor = ToSKColor(EntryBackgroundColor);
var isTransparent = bgColor.Alpha < 10; // Consider nearly transparent as transparent
if (!isTransparent)
{
// Draw background
using var bgPaint = new SKPaint
{
Color = bgColor,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
var rect = new SKRoundRect(bounds, (float)CornerRadius);
canvas.DrawRoundRect(rect, bgPaint);
// Draw border
var borderColor = IsFocused ? ToSKColor(FocusedBorderColor) : ToSKColor(BorderColor);
var borderWidth = IsFocused ? (float)BorderWidth + 1 : (float)BorderWidth;
using var borderPaint = new SKPaint
{
Color = borderColor,
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = borderWidth
};
canvas.DrawRoundRect(rect, borderPaint);
}
// Calculate content bounds
var contentBounds = new SKRect(
bounds.Left + (float)Padding.Left,
bounds.Top + (float)Padding.Top,
bounds.Right - (float)Padding.Right,
bounds.Bottom - (float)Padding.Bottom);
// Reserve space for clear button if shown
var clearButtonSize = 20f;
var clearButtonMargin = 8f;
var showClear = ShouldShowClearButton();
if (showClear)
{
contentBounds.Right -= clearButtonSize + clearButtonMargin;
}
// Set up clipping for text area
canvas.Save();
canvas.ClipRect(contentBounds);
var fontStyle = GetFontStyle();
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
?? SKTypeface.Default;
using var font = new SKFont(typeface, (float)FontSize);
using var paint = new SKPaint(font) { IsAntialias = true };
var displayText = GetDisplayText();
// Append pre-edit text at cursor position for IME composition display
var preEditInsertPos = Math.Min(_cursorPosition, displayText.Length);
var displayTextWithPreEdit = string.IsNullOrEmpty(_preEditText)
? displayText
: displayText.Insert(preEditInsertPos, _preEditText);
var hasText = !string.IsNullOrEmpty(displayTextWithPreEdit);
if (hasText)
{
paint.Color = GetEffectiveTextColor();
// Measure text to cursor position for scrolling
var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
var cursorX = paint.MeasureText(textToCursor);
// Auto-scroll to keep cursor visible
if (cursorX - _scrollOffset > contentBounds.Width - 10)
{
_scrollOffset = cursorX - contentBounds.Width + 10;
}
else if (cursorX - _scrollOffset < 0)
{
_scrollOffset = cursorX;
}
// Draw selection (check != 0 to handle both forward and backward selection)
if (IsFocused && _selectionLength != 0)
{
DrawSelection(canvas, paint, displayText, contentBounds);
}
// Calculate text position based on vertical alignment
var textBounds = new SKRect();
paint.MeasureText(displayText, ref textBounds);
float x = contentBounds.Left - _scrollOffset;
float y = VerticalTextAlignment switch
{
TextAlignment.Start => contentBounds.Top - textBounds.Top,
TextAlignment.End => contentBounds.Bottom - textBounds.Bottom,
_ => contentBounds.MidY - textBounds.MidY // Center
};
// Draw the text with font fallback for emoji/CJK support
DrawTextWithFallback(canvas, displayTextWithPreEdit, x, y, paint, typeface);
// Draw underline for pre-edit (composition) text
if (!string.IsNullOrEmpty(_preEditText))
{
DrawPreEditUnderline(canvas, paint, displayText, x, y, contentBounds);
}
// Draw cursor
if (IsFocused && !IsReadOnly && _cursorVisible)
{
DrawCursor(canvas, paint, displayText, contentBounds);
}
}
else if (!string.IsNullOrEmpty(Placeholder))
{
// Draw placeholder
paint.Color = GetEffectivePlaceholderColor();
var textBounds = new SKRect();
paint.MeasureText(Placeholder, ref textBounds);
float x = contentBounds.Left;
float y = contentBounds.MidY - textBounds.MidY;
canvas.DrawText(Placeholder, x, y, paint);
}
else if (IsFocused && !IsReadOnly && _cursorVisible)
{
// Draw cursor even with no text
DrawCursor(canvas, paint, "", contentBounds);
}
canvas.Restore();
// Draw clear button if applicable
if (showClear)
{
DrawClearButton(canvas, bounds, clearButtonSize, clearButtonMargin);
}
}
private bool ShouldShowClearButton()
{
if (string.IsNullOrEmpty(Text)) return false;
// Check both legacy ShowClearButton and MAUI ClearButtonVisibility
if (ShowClearButton && IsFocused) return true;
return ClearButtonVisibility switch
{
ClearButtonVisibility.WhileEditing => IsFocused,
ClearButtonVisibility.Never => false,
_ => false
};
}
private void DrawClearButton(SKCanvas canvas, SKRect bounds, float size, float margin)
{
var centerX = bounds.Right - margin - size / 2;
var centerY = bounds.MidY;
// Draw circle background
using var circlePaint = new SKPaint
{
Color = SkiaTheme.Gray400SK,
IsAntialias = true,
Style = SKPaintStyle.Fill
};
canvas.DrawCircle(centerX, centerY, size / 2 - 2, circlePaint);
// Draw X
using var xPaint = new SKPaint
{
Color = SkiaTheme.BackgroundWhiteSK,
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = 2,
StrokeCap = SKStrokeCap.Round
};
var offset = size / 4 - 1;
canvas.DrawLine(centerX - offset, centerY - offset, centerX + offset, centerY + offset, xPaint);
canvas.DrawLine(centerX - offset, centerY + offset, centerX + offset, centerY - offset, xPaint);
}
private void DrawSelection(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
{
var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength);
var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength);
var textToStart = displayText.Substring(0, selStart);
var textToEnd = displayText.Substring(0, selEnd);
var startX = bounds.Left - _scrollOffset + paint.MeasureText(textToStart);
var endX = bounds.Left - _scrollOffset + paint.MeasureText(textToEnd);
using var selPaint = new SKPaint
{
Color = ToSKColor(SelectionColor),
Style = SKPaintStyle.Fill
};
canvas.DrawRect(startX, bounds.Top, endX - startX, bounds.Height, selPaint);
}
private void DrawCursor(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
{
var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
var cursorX = bounds.Left - _scrollOffset + paint.MeasureText(textToCursor);
using var cursorPaint = new SKPaint
{
Color = ToSKColor(CursorColor),
StrokeWidth = 2,
IsAntialias = true
};
canvas.DrawLine(cursorX, bounds.Top + 2, cursorX, bounds.Bottom - 2, cursorPaint);
}
/// <summary>
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
=> TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, (float)FontSize);
/// <summary>
/// Draws underline for IME pre-edit (composition) text.
/// </summary>
private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
=> TextRenderingHelper.DrawPreEditUnderline(canvas, paint, displayText, _cursorPosition, _preEditText, x, y);
private void ResetCursorBlink()
{
_cursorBlinkTime = DateTime.UtcNow;
_cursorVisible = true;
}
/// <summary>
/// Updates cursor blink animation.
/// </summary>
public void UpdateCursorBlink()
{
if (!IsFocused) return;
var elapsed = (DateTime.UtcNow - _cursorBlinkTime).TotalMilliseconds;
var newVisible = ((int)(elapsed / 500) % 2) == 0;
if (newVisible != _cursorVisible)
{
_cursorVisible = newVisible;
Invalidate();
}
}
protected override Size MeasureOverride(Size availableSize)
{
var fontStyle = GetFontStyle();
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
?? SKTypeface.Default;
using var font = new SKFont(typeface, (float)FontSize);
// Use font metrics for consistent height regardless of text content
// This prevents size changes when placeholder disappears or text changes
var metrics = font.Metrics;
var textHeight = metrics.Descent - metrics.Ascent + metrics.Leading;
return new Size(
200, // Default width, will be overridden by layout
textHeight + Padding.Top + Padding.Bottom + BorderWidth * 2);
}
}

654
Views/SkiaEntry.Input.cs Normal file
View File

@@ -0,0 +1,654 @@
// 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 Microsoft.Maui.Controls;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform.Linux;
using Microsoft.Maui.Platform.Linux.Rendering;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public partial class SkiaEntry
{
#region IInputContext Implementation
/// <summary>
/// Gets or sets the text for IME context.
/// </summary>
string IInputContext.Text
{
get => Text;
set => Text = value;
}
/// <summary>
/// Gets or sets the cursor position for IME context.
/// </summary>
int IInputContext.CursorPosition
{
get => _cursorPosition;
set => CursorPosition = value;
}
/// <summary>
/// Gets the selection start for IME context.
/// </summary>
int IInputContext.SelectionStart => _selectionStart;
/// <summary>
/// Gets the selection length for IME context.
/// </summary>
int IInputContext.SelectionLength => _selectionLength;
/// <summary>
/// Called when IME commits text.
/// </summary>
public void OnTextCommitted(string text)
{
if (IsReadOnly) return;
// Delete selection if any
if (_selectionLength != 0)
{
DeleteSelection();
}
// Clear pre-edit text
_preEditText = string.Empty;
_preEditCursorPosition = 0;
// Check max length
if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
{
text = text.Substring(0, MaxLength - Text.Length);
}
// Insert committed text at cursor
var newText = Text.Insert(_cursorPosition, text);
var newPos = _cursorPosition + text.Length;
Text = newText;
_cursorPosition = newPos;
ResetCursorBlink();
Invalidate();
}
/// <summary>
/// Called when IME pre-edit (composition) text changes.
/// </summary>
public void OnPreEditChanged(string preEditText, int cursorPosition)
{
_preEditText = preEditText ?? string.Empty;
_preEditCursorPosition = cursorPosition;
Invalidate();
}
/// <summary>
/// Called when IME pre-edit ends (cancelled or committed).
/// </summary>
public void OnPreEditEnded()
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
Invalidate();
}
#endregion
public override void OnTextInput(TextInputEventArgs e)
{
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;
// Delete selection if any
if (_selectionLength != 0)
{
DeleteSelection();
}
// Check max length
if (MaxLength > 0 && Text.Length >= MaxLength)
return;
// Insert text at cursor
var insertText = e.Text;
if (MaxLength > 0)
{
var remaining = MaxLength - Text.Length;
insertText = insertText.Substring(0, Math.Min(insertText.Length, remaining));
}
var newText = Text.Insert(_cursorPosition, insertText);
var oldPos = _cursorPosition;
Text = newText;
_cursorPosition = oldPos + insertText.Length;
ResetCursorBlink();
Invalidate();
}
public override void OnKeyDown(KeyEventArgs e)
{
if (!IsEnabled) return;
switch (e.Key)
{
case Key.Backspace:
if (!IsReadOnly)
{
if (_selectionLength > 0)
{
DeleteSelection();
}
else if (_cursorPosition > 0)
{
var newText = Text.Remove(_cursorPosition - 1, 1);
var newPos = _cursorPosition - 1;
Text = newText;
_cursorPosition = newPos;
}
ResetCursorBlink();
Invalidate();
}
e.Handled = true;
break;
case Key.Delete:
if (!IsReadOnly)
{
if (_selectionLength > 0)
{
DeleteSelection();
}
else if (_cursorPosition < Text.Length)
{
Text = Text.Remove(_cursorPosition, 1);
}
ResetCursorBlink();
Invalidate();
}
e.Handled = true;
break;
case Key.Left:
if (_cursorPosition > 0)
{
if (e.Modifiers.HasFlag(KeyModifiers.Shift))
{
ExtendSelection(-1);
}
else
{
ClearSelection();
_cursorPosition--;
}
ResetCursorBlink();
Invalidate();
}
e.Handled = true;
break;
case Key.Right:
if (_cursorPosition < Text.Length)
{
if (e.Modifiers.HasFlag(KeyModifiers.Shift))
{
ExtendSelection(1);
}
else
{
ClearSelection();
_cursorPosition++;
}
ResetCursorBlink();
Invalidate();
}
e.Handled = true;
break;
case Key.Home:
if (e.Modifiers.HasFlag(KeyModifiers.Shift))
{
ExtendSelectionTo(0);
}
else
{
ClearSelection();
_cursorPosition = 0;
}
ResetCursorBlink();
Invalidate();
e.Handled = true;
break;
case Key.End:
if (e.Modifiers.HasFlag(KeyModifiers.Shift))
{
ExtendSelectionTo(Text.Length);
}
else
{
ClearSelection();
_cursorPosition = Text.Length;
}
ResetCursorBlink();
Invalidate();
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;
case Key.Enter:
Completed?.Invoke(this, EventArgs.Empty);
// Execute ReturnCommand if set and can execute
if (ReturnCommand?.CanExecute(ReturnCommandParameter) == true)
{
ReturnCommand.Execute(ReturnCommandParameter);
}
e.Handled = true;
break;
}
}
public override void OnPointerPressed(PointerEventArgs e)
{
if (!IsEnabled) return;
// Handle right-click context menu
if (e.Button == PointerButton.Right)
{
ShowContextMenu(e.X, e.Y);
return;
}
// Check if clicked on clear button
if (ShouldShowClearButton())
{
var clearButtonSize = 20f;
var clearButtonMargin = 8f;
var clearCenterX = (float)(Bounds.Left + Bounds.Width) - clearButtonMargin - clearButtonSize / 2;
var clearCenterY = (float)(Bounds.Top + Bounds.Height / 2);
var dx = e.X - clearCenterX;
var dy = e.Y - clearCenterY;
if (dx * dx + dy * dy < (clearButtonSize / 2) * (clearButtonSize / 2))
{
// Clear button clicked
Text = "";
_cursorPosition = 0;
_selectionLength = 0;
Invalidate();
return;
}
}
// Calculate cursor position from click using screen coordinates
var screenBounds = ScreenBounds;
var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
_cursorPosition = GetCharacterIndexAtX(clickX);
// Check for double-click (select word or select all)
var now = DateTime.UtcNow;
var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
var distanceFromLastClick = Math.Abs(e.X - _lastClickX);
if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
{
// Double-click: select all or select word based on property
if (SelectAllOnDoubleClick)
{
SelectAll();
}
else
{
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;
}
ResetCursorBlink();
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 == '_';
}
public override void OnPointerMoved(PointerEventArgs e)
{
if (!IsEnabled || !_isSelecting) return;
// Extend selection to current mouse position
var screenBounds = ScreenBounds;
var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
var newPosition = GetCharacterIndexAtX(clickX);
if (newPosition != _cursorPosition)
{
_cursorPosition = newPosition;
_selectionLength = _cursorPosition - _selectionStart;
ResetCursorBlink();
Invalidate();
}
}
public override void OnPointerReleased(PointerEventArgs e)
{
_isSelecting = false;
}
private int GetCharacterIndexAtX(float x)
{
if (string.IsNullOrEmpty(Text)) return 0;
var fontStyle = GetFontStyle();
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
?? SKTypeface.Default;
using var font = new SKFont(typeface, (float)FontSize);
using var paint = new SKPaint(font);
var displayText = GetDisplayText();
for (int i = 0; i <= displayText.Length; i++)
{
var substring = displayText.Substring(0, i);
var width = paint.MeasureText(substring);
if (width >= x)
{
// Check if closer to current or previous character
if (i > 0)
{
var prevWidth = paint.MeasureText(displayText.Substring(0, i - 1));
if (x - prevWidth < width - x)
return i - 1;
}
return i;
}
}
return displayText.Length;
}
private void DeleteSelection()
{
var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
var length = Math.Abs(_selectionLength);
Text = Text.Remove(start, length);
_cursorPosition = start;
_selectionLength = 0;
}
private void ClearSelection()
{
_selectionLength = 0;
}
private void ExtendSelection(int delta)
{
if (_selectionLength == 0)
{
_selectionStart = _cursorPosition;
}
_cursorPosition += delta;
_selectionLength = _cursorPosition - _selectionStart;
}
private void ExtendSelectionTo(int position)
{
if (_selectionLength == 0)
{
_selectionStart = _cursorPosition;
}
_cursorPosition = position;
_selectionLength = _cursorPosition - _selectionStart;
}
/// <summary>
/// Selects all text.
/// </summary>
public void SelectAll()
{
_selectionStart = 0;
_cursorPosition = Text.Length;
_selectionLength = Text.Length;
Invalidate();
}
private void CopyToClipboard()
{
// Password fields should not allow copying
if (IsPassword) return;
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()
{
// Password fields should not allow cutting
if (IsPassword) return;
CopyToClipboard();
DeleteSelection();
Invalidate();
}
private void PasteFromClipboard()
{
// Get from system clipboard
var text = SystemClipboard.GetText();
if (string.IsNullOrEmpty(text)) return;
if (_selectionLength != 0)
{
DeleteSelection();
}
// Check max length
if (MaxLength > 0)
{
var remaining = MaxLength - Text.Length;
text = text.Substring(0, Math.Min(text.Length, remaining));
}
var newText = Text.Insert(_cursorPosition, text);
var newPos = _cursorPosition + text.Length;
Text = newText;
_cursorPosition = newPos;
Invalidate();
}
private void ShowContextMenu(float x, float y)
{
DiagnosticLog.Debug("SkiaEntry", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
bool hasSelection = _selectionLength != 0;
bool hasText = !string.IsNullOrEmpty(Text);
bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
if (LinuxApplication.IsGtkMode)
{
// Use GTK context menu when running in GTK mode (e.g., with WebView)
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)
});
}
else
{
// Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
var items = new List<ContextMenuItem>
{
new ContextMenuItem("Cut", () =>
{
CutToClipboard();
Invalidate();
}, hasSelection),
new ContextMenuItem("Copy", () =>
{
CopyToClipboard();
}, hasSelection),
new ContextMenuItem("Paste", () =>
{
PasteFromClipboard();
Invalidate();
}, hasClipboard),
ContextMenuItem.Separator,
new ContextMenuItem("Select All", () =>
{
SelectAll();
Invalidate();
}, hasText)
};
var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
LinuxDialogService.ShowContextMenu(menu);
}
}
public override void OnFocusGained()
{
base.OnFocusGained();
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
// Connect to IME service
_inputMethodService?.SetFocus(this);
// Update cursor location for IME candidate window positioning
UpdateImeCursorLocation();
}
public override void OnFocusLost()
{
base.OnFocusLost();
SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
// Disconnect from IME service and reset any composition
_inputMethodService?.SetFocus(null);
_preEditText = string.Empty;
_preEditCursorPosition = 0;
}
/// <summary>
/// Updates the IME cursor location for candidate window positioning.
/// </summary>
private void UpdateImeCursorLocation()
{
if (_inputMethodService == null) return;
var screenBounds = ScreenBounds;
var fontStyle = GetFontStyle();
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
?? SKTypeface.Default;
using var font = new SKFont(typeface, (float)FontSize);
using var paint = new SKPaint(font);
var displayText = GetDisplayText();
var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
var cursorX = paint.MeasureText(textToCursor);
int x = (int)(screenBounds.Left + Padding.Left - _scrollOffset + cursorX);
int y = (int)(screenBounds.Top + Padding.Top);
int height = (int)FontSize;
_inputMethodService.SetCursorLocation(x, y, 2, height);
}
}

View File

File diff suppressed because it is too large Load Diff

500
Views/SkiaGrid.cs Normal file
View File

@@ -0,0 +1,500 @@
// 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.Graphics;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
using Microsoft.Maui;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Grid layout that arranges children in rows and columns.
/// </summary>
public class SkiaGrid : SkiaLayoutView
{
#region BindableProperties
/// <summary>
/// Bindable property for RowSpacing.
/// </summary>
public static readonly BindableProperty RowSpacingProperty =
BindableProperty.Create(
nameof(RowSpacing),
typeof(float),
typeof(SkiaGrid),
0f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
/// <summary>
/// Bindable property for ColumnSpacing.
/// </summary>
public static readonly BindableProperty ColumnSpacingProperty =
BindableProperty.Create(
nameof(ColumnSpacing),
typeof(float),
typeof(SkiaGrid),
0f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
#endregion
private readonly List<GridLength> _rowDefinitions = new();
private readonly List<GridLength> _columnDefinitions = new();
private readonly Dictionary<SkiaView, GridPosition> _childPositions = new();
private float[] _rowHeights = Array.Empty<float>();
private float[] _columnWidths = Array.Empty<float>();
/// <summary>
/// Gets the row definitions.
/// </summary>
public IList<GridLength> RowDefinitions => _rowDefinitions;
/// <summary>
/// Gets the column definitions.
/// </summary>
public IList<GridLength> ColumnDefinitions => _columnDefinitions;
/// <summary>
/// Spacing between rows.
/// </summary>
public float RowSpacing
{
get => (float)GetValue(RowSpacingProperty);
set => SetValue(RowSpacingProperty, value);
}
/// <summary>
/// Spacing between columns.
/// </summary>
public float ColumnSpacing
{
get => (float)GetValue(ColumnSpacingProperty);
set => SetValue(ColumnSpacingProperty, value);
}
/// <summary>
/// Adds a child at the specified grid position.
/// </summary>
public void AddChild(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
{
base.AddChild(child);
_childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
}
public override void RemoveChild(SkiaView child)
{
base.RemoveChild(child);
_childPositions.Remove(child);
}
/// <summary>
/// Gets the grid position of a child.
/// </summary>
public GridPosition GetPosition(SkiaView child)
{
return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
}
/// <summary>
/// Sets the grid position of a child.
/// </summary>
public void SetPosition(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
{
_childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
InvalidateMeasure();
Invalidate();
}
protected override Size MeasureOverride(Size availableSize)
{
var contentWidth = (float)(availableSize.Width - Padding.Left - Padding.Right);
var contentHeight = (float)(availableSize.Height - Padding.Top - Padding.Bottom);
// Handle NaN/Infinity
if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
// 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 Size(double.PositiveInfinity, double.PositiveInfinity));
var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)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 Size(cellWidth, double.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 = (float)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;
var pos = GetPosition(child);
var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
var cellHeight = GetCellHeight(pos.Row, pos.RowSpan);
child.Measure(new Size(cellWidth, cellHeight));
}
// Calculate total size
var totalWidth = _columnWidths.Sum() + Math.Max(0, columnCount - 1) * ColumnSpacing;
var totalHeight = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
return new Size(
totalWidth + Padding.Left + Padding.Right,
totalHeight + Padding.Top + Padding.Bottom);
}
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 };
var sizes = new float[count];
var totalSpacing = Math.Max(0, count - 1) * spacing;
var remainingSpace = available - totalSpacing;
// First pass: absolute and auto sizes
float starTotal = 0;
for (int i = 0; i < count; i++)
{
var def = i < definitions.Count ? definitions[i] : GridLength.Star;
if (def.IsAbsolute)
{
sizes[i] = def.Value;
remainingSpace -= def.Value;
}
else if (def.IsAuto)
{
// Use natural size from measured children
sizes[i] = naturalSizes[i];
remainingSpace -= sizes[i];
}
else if (def.IsStar)
{
starTotal += def.Value;
}
}
// Second pass: star sizes (distribute remaining space)
if (starTotal > 0 && remainingSpace > 0)
{
for (int i = 0; i < count; i++)
{
var def = i < definitions.Count ? definitions[i] : GridLength.Star;
if (def.IsStar)
{
sizes[i] = (def.Value / starTotal) * remainingSpace;
}
}
}
return sizes;
}
private float GetCellWidth(int column, int span)
{
float width = 0;
for (int i = column; i < Math.Min(column + span, _columnWidths.Length); i++)
{
width += _columnWidths[i];
if (i > column) width += ColumnSpacing;
}
return width;
}
private float GetCellHeight(int row, int span)
{
float height = 0;
for (int i = row; i < Math.Min(row + span, _rowHeights.Length); i++)
{
height += _rowHeights[i];
if (i > row) height += RowSpacing;
}
return height;
}
private float GetColumnOffset(int column)
{
float offset = 0;
for (int i = 0; i < Math.Min(column, _columnWidths.Length); i++)
{
offset += _columnWidths[i] + ColumnSpacing;
}
return offset;
}
private float GetRowOffset(int row)
{
float offset = 0;
for (int i = 0; i < Math.Min(row, _rowHeights.Length); i++)
{
offset += _rowHeights[i] + RowSpacing;
}
return offset;
}
protected override Rect ArrangeOverride(Rect bounds)
{
try
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
// 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)
{
if (!child.IsVisible) continue;
var pos = GetPosition(child);
var x = content.Left + GetColumnOffset(pos.Column);
// 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 cellX = x + (float)margin.Left;
var cellY = y + (float)margin.Top;
var cellWidth = width - (float)margin.Left - (float)margin.Right;
var cellHeight = height - (float)margin.Top - (float)margin.Bottom;
// Get child's desired size
var childDesiredSize = child.Measure(new Size(cellWidth, cellHeight));
var childWidth = (float)childDesiredSize.Width;
var childHeight = (float)childDesiredSize.Height;
var vAlign = (int)child.VerticalOptions.Alignment;
// Apply HorizontalOptions
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
float finalX = cellX;
float finalWidth = cellWidth;
var hAlign = (int)child.HorizontalOptions.Alignment;
if (hAlign != 3 && childWidth < cellWidth && childWidth > 0) // 3 = Fill
{
finalWidth = childWidth;
if (hAlign == 1) // Center
finalX = cellX + (cellWidth - childWidth) / 2;
else if (hAlign == 2) // End
finalX = cellX + cellWidth - childWidth;
}
// Apply VerticalOptions
float finalY = cellY;
float finalHeight = cellHeight;
// vAlign already calculated above for debug logging
if (vAlign != 3 && childHeight < cellHeight && childHeight > 0) // 3 = Fill
{
finalHeight = childHeight;
if (vAlign == 1) // Center
finalY = cellY + (cellHeight - childHeight) / 2;
else if (vAlign == 2) // End
finalY = cellY + cellHeight - childHeight;
}
child.Arrange(new Rect(finalX, finalY, finalWidth, finalHeight));
}
return bounds;
}
catch (Exception ex)
{
DiagnosticLog.Error("SkiaGrid", $"EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}", ex);
DiagnosticLog.Error("SkiaGrid", $"Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
DiagnosticLog.Error("SkiaGrid", $"Stack trace: {ex.StackTrace}");
throw;
}
}
}
/// <summary>
/// Grid position information.
/// </summary>
public readonly struct GridPosition
{
public int Row { get; }
public int Column { get; }
public int RowSpan { get; }
public int ColumnSpan { get; }
public GridPosition(int row, int column, int rowSpan = 1, int columnSpan = 1)
{
Row = row;
Column = column;
RowSpan = Math.Max(1, rowSpan);
ColumnSpan = Math.Max(1, columnSpan);
}
}
/// <summary>
/// Grid length specification.
/// </summary>
public readonly struct GridLength
{
public float Value { get; }
public GridUnitType GridUnitType { get; }
public bool IsAbsolute => GridUnitType == GridUnitType.Absolute;
public bool IsAuto => GridUnitType == GridUnitType.Auto;
public bool IsStar => GridUnitType == GridUnitType.Star;
public static GridLength Auto => new(1, GridUnitType.Auto);
public static GridLength Star => new(1, GridUnitType.Star);
public GridLength(float value, GridUnitType unitType = GridUnitType.Absolute)
{
Value = value;
GridUnitType = unitType;
}
public static GridLength FromAbsolute(float value) => new(value, GridUnitType.Absolute);
public static GridLength FromStar(float value = 1) => new(value, GridUnitType.Star);
}
/// <summary>
/// Grid unit type options.
/// </summary>
public enum GridUnitType
{
Absolute,
Star,
Auto
}

View File

@@ -606,11 +606,7 @@ public class SkiaLabel : SkiaView
OnTextChanged();
}
private SKColor ToSKColor(Color? color)
{
if (color == null) return SkiaTheme.TextPrimarySK;
return color.ToSKColor();
}
private SKColor ToSKColor(Color? color) => TextRenderingHelper.ToSKColor(color, SkiaTheme.TextPrimarySK);
private string GetDisplayText()
{
@@ -631,16 +627,7 @@ public class SkiaLabel : SkiaView
};
}
private SKFontStyle GetFontStyle()
{
bool isBold = FontAttributes.HasFlag(FontAttributes.Bold);
bool isItalic = FontAttributes.HasFlag(FontAttributes.Italic);
return new SKFontStyle(
isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
}
private SKFontStyle GetFontStyle() => TextRenderingHelper.GetFontStyle(FontAttributes);
/// <summary>
/// Determines if text should be rendered right-to-left based on FlowDirection.
@@ -878,39 +865,7 @@ public class SkiaLabel : SkiaView
/// Draws text with font fallback for emoji, CJK, and other scripts.
/// </summary>
private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
{
if (string.IsNullOrEmpty(text))
{
return;
}
// Use FontFallbackManager for mixed-script text
var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface);
if (runs.Count <= 1)
{
// Single run or no fallback needed - draw directly
canvas.DrawText(text, x, y, paint);
return;
}
// Multiple runs with different fonts
float fontSize = FontSize > 0 ? (float)FontSize : 14f;
float currentX = x;
foreach (var run in runs)
{
using var runFont = new SKFont(run.Typeface, fontSize);
using var runPaint = new SKPaint(runFont)
{
Color = paint.Color,
IsAntialias = true
};
canvas.DrawText(run.Text, currentX, y, runPaint);
currentX += runPaint.MeasureText(run.Text);
}
}
=> TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, FontSize > 0 ? (float)FontSize : 14f);
/// <summary>
/// Draws formatted span text with font fallback for emoji, CJK, and other scripts.

View File

@@ -315,860 +315,3 @@ public abstract class SkiaLayoutView : SkiaView
}
}
}
/// <summary>
/// Stack layout that arranges children in a horizontal or vertical line.
/// </summary>
public class SkiaStackLayout : SkiaLayoutView
{
/// <summary>
/// Bindable property for Orientation.
/// </summary>
public static readonly BindableProperty OrientationProperty =
BindableProperty.Create(
nameof(Orientation),
typeof(StackOrientation),
typeof(SkiaStackLayout),
StackOrientation.Vertical,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
/// <summary>
/// Gets or sets the orientation of the stack.
/// </summary>
public StackOrientation Orientation
{
get => (StackOrientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
// Handle NaN/Infinity in padding
var paddingLeft = (float)(double.IsNaN(Padding.Left) ? 0 : Padding.Left);
var paddingRight = (float)(double.IsNaN(Padding.Right) ? 0 : Padding.Right);
var paddingTop = (float)(double.IsNaN(Padding.Top) ? 0 : Padding.Top);
var paddingBottom = (float)(double.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom);
var contentWidth = (float)availableSize.Width - paddingLeft - paddingRight;
var contentHeight = (float)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;
float maxWidth = 0;
float maxHeight = 0;
// For stack layouts, give children infinite size in the stacking direction
// so they can measure to their natural size
var childAvailable = Orientation == StackOrientation.Horizontal
? new Size(double.PositiveInfinity, contentHeight) // Horizontal: infinite width, constrained height
: new Size(contentWidth, double.PositiveInfinity); // Vertical: constrained width, infinite height
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var childSize = child.Measure(childAvailable);
// Skip NaN sizes from child measurements
var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
var childHeight = double.IsNaN(childSize.Height) ? 0f : (float)childSize.Height;
if (Orientation == StackOrientation.Vertical)
{
totalHeight += childHeight;
maxWidth = Math.Max(maxWidth, childWidth);
}
else
{
totalWidth += childWidth;
maxHeight = Math.Max(maxHeight, childHeight);
}
}
// Add spacing
var visibleCount = Children.Count(c => c.IsVisible);
var totalSpacing = (float)(Math.Max(0, visibleCount - 1) * Spacing);
if (Orientation == StackOrientation.Vertical)
{
totalHeight += totalSpacing;
return new Size(
maxWidth + paddingLeft + paddingRight,
totalHeight + paddingTop + paddingBottom);
}
else
{
totalWidth += totalSpacing;
return new Size(
totalWidth + paddingLeft + paddingRight,
maxHeight + paddingTop + paddingBottom);
}
}
protected override Rect ArrangeOverride(Rect bounds)
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
// 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)
{
if (!child.IsVisible) continue;
var childDesired = child.DesiredSize;
// Handle NaN and Infinity in desired size
var childWidth = double.IsNaN(childDesired.Width) || double.IsInfinity(childDesired.Width)
? contentWidth
: (float)childDesired.Width;
var childHeight = double.IsNaN(childDesired.Height) || double.IsInfinity(childDesired.Height)
? contentHeight
: (float)childDesired.Height;
float childBoundsLeft, childBoundsTop, childBoundsWidth, childBoundsHeight;
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);
// Respect child's HorizontalOptions for vertical layouts
var useWidth = Math.Min(childWidth, contentWidth);
float childLeft = content.Left;
var horizontalOptions = child.HorizontalOptions;
var alignmentValue = (int)horizontalOptions.Alignment;
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
if (alignmentValue == 1) // Center
{
childLeft = content.Left + (contentWidth - useWidth) / 2;
}
else if (alignmentValue == 2) // End
{
childLeft = content.Left + contentWidth - useWidth;
}
else if (alignmentValue == 3) // Fill
{
useWidth = contentWidth;
}
childBoundsLeft = childLeft;
childBoundsTop = content.Top + offset;
childBoundsWidth = useWidth;
childBoundsHeight = useHeight;
offset += useHeight + (float)Spacing;
}
else
{
// Horizontal stack: give each child its measured width
// Don't constrain - let content overflow if needed (parent clips)
var useWidth = childWidth;
// Respect child's VerticalOptions for horizontal layouts
var useHeight = Math.Min(childHeight, contentHeight);
float childTop = content.Top;
float childBottomCalc = 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;
childBottomCalc = childTop + useHeight;
}
else if (alignmentValue == 2) // End
{
childTop = content.Top + contentHeight - useHeight;
childBottomCalc = content.Top + contentHeight;
}
else if (alignmentValue == 3) // Fill
{
childTop = content.Top;
childBottomCalc = content.Top + contentHeight;
}
childBoundsLeft = content.Left + offset;
childBoundsTop = childTop;
childBoundsWidth = useWidth;
childBoundsHeight = childBottomCalc - childTop;
offset += useWidth + (float)Spacing;
}
// Apply child's margin
var margin = child.Margin;
var marginedBounds = new Rect(
childBoundsLeft + (float)margin.Left,
childBoundsTop + (float)margin.Top,
childBoundsWidth - (float)margin.Left - (float)margin.Right,
childBoundsHeight - (float)margin.Top - (float)margin.Bottom);
child.Arrange(marginedBounds);
}
return bounds;
}
}
/// <summary>
/// Stack orientation options.
/// </summary>
public enum StackOrientation
{
Vertical,
Horizontal
}
/// <summary>
/// Grid layout that arranges children in rows and columns.
/// </summary>
public class SkiaGrid : SkiaLayoutView
{
#region BindableProperties
/// <summary>
/// Bindable property for RowSpacing.
/// </summary>
public static readonly BindableProperty RowSpacingProperty =
BindableProperty.Create(
nameof(RowSpacing),
typeof(float),
typeof(SkiaGrid),
0f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
/// <summary>
/// Bindable property for ColumnSpacing.
/// </summary>
public static readonly BindableProperty ColumnSpacingProperty =
BindableProperty.Create(
nameof(ColumnSpacing),
typeof(float),
typeof(SkiaGrid),
0f,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
#endregion
private readonly List<GridLength> _rowDefinitions = new();
private readonly List<GridLength> _columnDefinitions = new();
private readonly Dictionary<SkiaView, GridPosition> _childPositions = new();
private float[] _rowHeights = Array.Empty<float>();
private float[] _columnWidths = Array.Empty<float>();
/// <summary>
/// Gets the row definitions.
/// </summary>
public IList<GridLength> RowDefinitions => _rowDefinitions;
/// <summary>
/// Gets the column definitions.
/// </summary>
public IList<GridLength> ColumnDefinitions => _columnDefinitions;
/// <summary>
/// Spacing between rows.
/// </summary>
public float RowSpacing
{
get => (float)GetValue(RowSpacingProperty);
set => SetValue(RowSpacingProperty, value);
}
/// <summary>
/// Spacing between columns.
/// </summary>
public float ColumnSpacing
{
get => (float)GetValue(ColumnSpacingProperty);
set => SetValue(ColumnSpacingProperty, value);
}
/// <summary>
/// Adds a child at the specified grid position.
/// </summary>
public void AddChild(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
{
base.AddChild(child);
_childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
}
public override void RemoveChild(SkiaView child)
{
base.RemoveChild(child);
_childPositions.Remove(child);
}
/// <summary>
/// Gets the grid position of a child.
/// </summary>
public GridPosition GetPosition(SkiaView child)
{
return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
}
/// <summary>
/// Sets the grid position of a child.
/// </summary>
public void SetPosition(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
{
_childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
InvalidateMeasure();
Invalidate();
}
protected override Size MeasureOverride(Size availableSize)
{
var contentWidth = (float)(availableSize.Width - Padding.Left - Padding.Right);
var contentHeight = (float)(availableSize.Height - Padding.Top - Padding.Bottom);
// Handle NaN/Infinity
if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
// 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 Size(double.PositiveInfinity, double.PositiveInfinity));
var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)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 Size(cellWidth, double.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 = (float)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;
var pos = GetPosition(child);
var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
var cellHeight = GetCellHeight(pos.Row, pos.RowSpan);
child.Measure(new Size(cellWidth, cellHeight));
}
// Calculate total size
var totalWidth = _columnWidths.Sum() + Math.Max(0, columnCount - 1) * ColumnSpacing;
var totalHeight = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
return new Size(
totalWidth + Padding.Left + Padding.Right,
totalHeight + Padding.Top + Padding.Bottom);
}
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 };
var sizes = new float[count];
var totalSpacing = Math.Max(0, count - 1) * spacing;
var remainingSpace = available - totalSpacing;
// First pass: absolute and auto sizes
float starTotal = 0;
for (int i = 0; i < count; i++)
{
var def = i < definitions.Count ? definitions[i] : GridLength.Star;
if (def.IsAbsolute)
{
sizes[i] = def.Value;
remainingSpace -= def.Value;
}
else if (def.IsAuto)
{
// Use natural size from measured children
sizes[i] = naturalSizes[i];
remainingSpace -= sizes[i];
}
else if (def.IsStar)
{
starTotal += def.Value;
}
}
// Second pass: star sizes (distribute remaining space)
if (starTotal > 0 && remainingSpace > 0)
{
for (int i = 0; i < count; i++)
{
var def = i < definitions.Count ? definitions[i] : GridLength.Star;
if (def.IsStar)
{
sizes[i] = (def.Value / starTotal) * remainingSpace;
}
}
}
return sizes;
}
private float GetCellWidth(int column, int span)
{
float width = 0;
for (int i = column; i < Math.Min(column + span, _columnWidths.Length); i++)
{
width += _columnWidths[i];
if (i > column) width += ColumnSpacing;
}
return width;
}
private float GetCellHeight(int row, int span)
{
float height = 0;
for (int i = row; i < Math.Min(row + span, _rowHeights.Length); i++)
{
height += _rowHeights[i];
if (i > row) height += RowSpacing;
}
return height;
}
private float GetColumnOffset(int column)
{
float offset = 0;
for (int i = 0; i < Math.Min(column, _columnWidths.Length); i++)
{
offset += _columnWidths[i] + ColumnSpacing;
}
return offset;
}
private float GetRowOffset(int row)
{
float offset = 0;
for (int i = 0; i < Math.Min(row, _rowHeights.Length); i++)
{
offset += _rowHeights[i] + RowSpacing;
}
return offset;
}
protected override Rect ArrangeOverride(Rect bounds)
{
try
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
// 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)
{
if (!child.IsVisible) continue;
var pos = GetPosition(child);
var x = content.Left + GetColumnOffset(pos.Column);
// 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 cellX = x + (float)margin.Left;
var cellY = y + (float)margin.Top;
var cellWidth = width - (float)margin.Left - (float)margin.Right;
var cellHeight = height - (float)margin.Top - (float)margin.Bottom;
// Get child's desired size
var childDesiredSize = child.Measure(new Size(cellWidth, cellHeight));
var childWidth = (float)childDesiredSize.Width;
var childHeight = (float)childDesiredSize.Height;
var vAlign = (int)child.VerticalOptions.Alignment;
// Apply HorizontalOptions
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
float finalX = cellX;
float finalWidth = cellWidth;
var hAlign = (int)child.HorizontalOptions.Alignment;
if (hAlign != 3 && childWidth < cellWidth && childWidth > 0) // 3 = Fill
{
finalWidth = childWidth;
if (hAlign == 1) // Center
finalX = cellX + (cellWidth - childWidth) / 2;
else if (hAlign == 2) // End
finalX = cellX + cellWidth - childWidth;
}
// Apply VerticalOptions
float finalY = cellY;
float finalHeight = cellHeight;
// vAlign already calculated above for debug logging
if (vAlign != 3 && childHeight < cellHeight && childHeight > 0) // 3 = Fill
{
finalHeight = childHeight;
if (vAlign == 1) // Center
finalY = cellY + (cellHeight - childHeight) / 2;
else if (vAlign == 2) // End
finalY = cellY + cellHeight - childHeight;
}
child.Arrange(new Rect(finalX, finalY, finalWidth, finalHeight));
}
return bounds;
}
catch (Exception ex)
{
DiagnosticLog.Error("SkiaGrid", $"EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}", ex);
DiagnosticLog.Error("SkiaGrid", $"Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
DiagnosticLog.Error("SkiaGrid", $"Stack trace: {ex.StackTrace}");
throw;
}
}
}
/// <summary>
/// Grid position information.
/// </summary>
public readonly struct GridPosition
{
public int Row { get; }
public int Column { get; }
public int RowSpan { get; }
public int ColumnSpan { get; }
public GridPosition(int row, int column, int rowSpan = 1, int columnSpan = 1)
{
Row = row;
Column = column;
RowSpan = Math.Max(1, rowSpan);
ColumnSpan = Math.Max(1, columnSpan);
}
}
/// <summary>
/// Grid length specification.
/// </summary>
public readonly struct GridLength
{
public float Value { get; }
public GridUnitType GridUnitType { get; }
public bool IsAbsolute => GridUnitType == GridUnitType.Absolute;
public bool IsAuto => GridUnitType == GridUnitType.Auto;
public bool IsStar => GridUnitType == GridUnitType.Star;
public static GridLength Auto => new(1, GridUnitType.Auto);
public static GridLength Star => new(1, GridUnitType.Star);
public GridLength(float value, GridUnitType unitType = GridUnitType.Absolute)
{
Value = value;
GridUnitType = unitType;
}
public static GridLength FromAbsolute(float value) => new(value, GridUnitType.Absolute);
public static GridLength FromStar(float value = 1) => new(value, GridUnitType.Star);
}
/// <summary>
/// Grid unit type options.
/// </summary>
public enum GridUnitType
{
Absolute,
Star,
Auto
}
/// <summary>
/// Absolute layout that positions children at exact coordinates.
/// </summary>
public class SkiaAbsoluteLayout : SkiaLayoutView
{
private readonly Dictionary<SkiaView, AbsoluteLayoutBounds> _childBounds = new();
/// <summary>
/// Adds a child at the specified position and size.
/// </summary>
public void AddChild(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
{
base.AddChild(child);
_childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
}
public override void RemoveChild(SkiaView child)
{
base.RemoveChild(child);
_childBounds.Remove(child);
}
/// <summary>
/// Gets the layout bounds for a child.
/// </summary>
public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
{
return _childBounds.TryGetValue(child, out var bounds)
? bounds
: new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
}
/// <summary>
/// Sets the layout bounds for a child.
/// </summary>
public void SetLayoutBounds(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
{
_childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
InvalidateMeasure();
Invalidate();
}
protected override Size MeasureOverride(Size availableSize)
{
float maxRight = 0;
float maxBottom = 0;
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var layout = GetLayoutBounds(child);
var bounds = layout.Bounds;
child.Measure(new Size(bounds.Width, bounds.Height));
maxRight = Math.Max(maxRight, bounds.Right);
maxBottom = Math.Max(maxBottom, bounds.Bottom);
}
return new Size(
maxRight + Padding.Left + Padding.Right,
maxBottom + Padding.Top + Padding.Bottom);
}
protected override Rect ArrangeOverride(Rect bounds)
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var layout = GetLayoutBounds(child);
var childBounds = layout.Bounds;
var flags = layout.Flags;
float x, y, width, height;
// X position
if (flags.HasFlag(AbsoluteLayoutFlags.XProportional))
x = content.Left + childBounds.Left * content.Width;
else
x = content.Left + childBounds.Left;
// Y position
if (flags.HasFlag(AbsoluteLayoutFlags.YProportional))
y = content.Top + childBounds.Top * content.Height;
else
y = content.Top + childBounds.Top;
// Width
if (flags.HasFlag(AbsoluteLayoutFlags.WidthProportional))
width = childBounds.Width * content.Width;
else if (childBounds.Width < 0)
width = (float)child.DesiredSize.Width;
else
width = childBounds.Width;
// Height
if (flags.HasFlag(AbsoluteLayoutFlags.HeightProportional))
height = childBounds.Height * content.Height;
else if (childBounds.Height < 0)
height = (float)child.DesiredSize.Height;
else
height = childBounds.Height;
// Apply child's margin
var margin = child.Margin;
var marginedBounds = new Rect(
x + (float)margin.Left,
y + (float)margin.Top,
width - (float)margin.Left - (float)margin.Right,
height - (float)margin.Top - (float)margin.Bottom);
child.Arrange(marginedBounds);
}
return bounds;
}
}
/// <summary>
/// Absolute layout bounds for a child.
/// </summary>
public readonly struct AbsoluteLayoutBounds
{
public SKRect Bounds { get; }
public AbsoluteLayoutFlags Flags { get; }
public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
{
Bounds = bounds;
Flags = flags;
}
}
/// <summary>
/// Flags for absolute layout positioning.
/// </summary>
[Flags]
public enum AbsoluteLayoutFlags
{
None = 0,
XProportional = 1,
YProportional = 2,
WidthProportional = 4,
HeightProportional = 8,
PositionProportional = XProportional | YProportional,
SizeProportional = WidthProportional | HeightProportional,
All = XProportional | YProportional | WidthProportional | HeightProportional
}

224
Views/SkiaStackLayout.cs Normal file
View File

@@ -0,0 +1,224 @@
// 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.Graphics;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
using Microsoft.Maui;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Stack layout that arranges children in a horizontal or vertical line.
/// </summary>
public class SkiaStackLayout : SkiaLayoutView
{
/// <summary>
/// Bindable property for Orientation.
/// </summary>
public static readonly BindableProperty OrientationProperty =
BindableProperty.Create(
nameof(Orientation),
typeof(StackOrientation),
typeof(SkiaStackLayout),
StackOrientation.Vertical,
BindingMode.TwoWay,
propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
/// <summary>
/// Gets or sets the orientation of the stack.
/// </summary>
public StackOrientation Orientation
{
get => (StackOrientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
// Handle NaN/Infinity in padding
var paddingLeft = (float)(double.IsNaN(Padding.Left) ? 0 : Padding.Left);
var paddingRight = (float)(double.IsNaN(Padding.Right) ? 0 : Padding.Right);
var paddingTop = (float)(double.IsNaN(Padding.Top) ? 0 : Padding.Top);
var paddingBottom = (float)(double.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom);
var contentWidth = (float)availableSize.Width - paddingLeft - paddingRight;
var contentHeight = (float)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;
float maxWidth = 0;
float maxHeight = 0;
// For stack layouts, give children infinite size in the stacking direction
// so they can measure to their natural size
var childAvailable = Orientation == StackOrientation.Horizontal
? new Size(double.PositiveInfinity, contentHeight) // Horizontal: infinite width, constrained height
: new Size(contentWidth, double.PositiveInfinity); // Vertical: constrained width, infinite height
foreach (var child in Children)
{
if (!child.IsVisible) continue;
var childSize = child.Measure(childAvailable);
// Skip NaN sizes from child measurements
var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
var childHeight = double.IsNaN(childSize.Height) ? 0f : (float)childSize.Height;
if (Orientation == StackOrientation.Vertical)
{
totalHeight += childHeight;
maxWidth = Math.Max(maxWidth, childWidth);
}
else
{
totalWidth += childWidth;
maxHeight = Math.Max(maxHeight, childHeight);
}
}
// Add spacing
var visibleCount = Children.Count(c => c.IsVisible);
var totalSpacing = (float)(Math.Max(0, visibleCount - 1) * Spacing);
if (Orientation == StackOrientation.Vertical)
{
totalHeight += totalSpacing;
return new Size(
maxWidth + paddingLeft + paddingRight,
totalHeight + paddingTop + paddingBottom);
}
else
{
totalWidth += totalSpacing;
return new Size(
totalWidth + paddingLeft + paddingRight,
maxHeight + paddingTop + paddingBottom);
}
}
protected override Rect ArrangeOverride(Rect bounds)
{
var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
// 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)
{
if (!child.IsVisible) continue;
var childDesired = child.DesiredSize;
// Handle NaN and Infinity in desired size
var childWidth = double.IsNaN(childDesired.Width) || double.IsInfinity(childDesired.Width)
? contentWidth
: (float)childDesired.Width;
var childHeight = double.IsNaN(childDesired.Height) || double.IsInfinity(childDesired.Height)
? contentHeight
: (float)childDesired.Height;
float childBoundsLeft, childBoundsTop, childBoundsWidth, childBoundsHeight;
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);
// Respect child's HorizontalOptions for vertical layouts
var useWidth = Math.Min(childWidth, contentWidth);
float childLeft = content.Left;
var horizontalOptions = child.HorizontalOptions;
var alignmentValue = (int)horizontalOptions.Alignment;
// LayoutAlignment: Start=0, Center=1, End=2, Fill=3
if (alignmentValue == 1) // Center
{
childLeft = content.Left + (contentWidth - useWidth) / 2;
}
else if (alignmentValue == 2) // End
{
childLeft = content.Left + contentWidth - useWidth;
}
else if (alignmentValue == 3) // Fill
{
useWidth = contentWidth;
}
childBoundsLeft = childLeft;
childBoundsTop = content.Top + offset;
childBoundsWidth = useWidth;
childBoundsHeight = useHeight;
offset += useHeight + (float)Spacing;
}
else
{
// Horizontal stack: give each child its measured width
// Don't constrain - let content overflow if needed (parent clips)
var useWidth = childWidth;
// Respect child's VerticalOptions for horizontal layouts
var useHeight = Math.Min(childHeight, contentHeight);
float childTop = content.Top;
float childBottomCalc = 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;
childBottomCalc = childTop + useHeight;
}
else if (alignmentValue == 2) // End
{
childTop = content.Top + contentHeight - useHeight;
childBottomCalc = content.Top + contentHeight;
}
else if (alignmentValue == 3) // Fill
{
childTop = content.Top;
childBottomCalc = content.Top + contentHeight;
}
childBoundsLeft = content.Left + offset;
childBoundsTop = childTop;
childBoundsWidth = useWidth;
childBoundsHeight = childBottomCalc - childTop;
offset += useWidth + (float)Spacing;
}
// Apply child's margin
var margin = child.Margin;
var marginedBounds = new Rect(
childBoundsLeft + (float)margin.Left,
childBoundsTop + (float)margin.Top,
childBoundsWidth - (float)margin.Left - (float)margin.Right,
childBoundsHeight - (float)margin.Top - (float)margin.Bottom);
child.Arrange(marginedBounds);
}
return bounds;
}
}
/// <summary>
/// Stack orientation options.
/// </summary>
public enum StackOrientation
{
Vertical,
Horizontal
}

View File

@@ -0,0 +1,311 @@
// 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.Rendering;
using Microsoft.Maui.Platform.Linux.Services;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public abstract partial class SkiaView
{
// Popup overlay system for dropdowns, calendars, etc.
private static readonly List<(SkiaView Owner, Action<SKCanvas> Draw)> _popupOverlays = new();
public static void RegisterPopupOverlay(SkiaView owner, Action<SKCanvas> drawAction)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
_popupOverlays.Add((owner, drawAction));
}
public static void UnregisterPopupOverlay(SkiaView owner)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
}
public static void DrawPopupOverlays(SKCanvas canvas)
{
// Restore canvas to clean state for overlay drawing
// Save count tells us how many unmatched Saves there are
while (canvas.SaveCount > 1)
{
canvas.Restore();
}
foreach (var (_, draw) in _popupOverlays)
{
canvas.Save();
draw(canvas);
canvas.Restore();
}
}
/// <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 High Contrast Support
private static HighContrastService? _highContrastService;
private static bool _highContrastInitialized;
/// <summary>
/// Gets whether high contrast mode is enabled.
/// </summary>
public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
/// <summary>
/// Gets the current high contrast colors, or default colors if not in high contrast mode.
/// </summary>
public static HighContrastColors GetHighContrastColors()
{
InitializeHighContrastService();
return _highContrastService?.GetColors() ?? new HighContrastColors
{
Background = SkiaTheme.BackgroundWhiteSK,
Foreground = SkiaTheme.TextPrimarySK,
Accent = SkiaTheme.PrimarySK,
Border = SkiaTheme.BorderMediumSK,
Error = SkiaTheme.ErrorSK,
Success = SkiaTheme.SuccessSK,
Warning = SkiaTheme.WarningSK,
Link = SkiaTheme.TextLinkSK,
LinkVisited = SkiaTheme.TextLinkVisitedSK,
Selection = SkiaTheme.PrimarySK,
SelectionText = SkiaTheme.BackgroundWhiteSK,
DisabledText = SkiaTheme.TextDisabledSK,
DisabledBackground = SkiaTheme.BackgroundDisabledSK
};
}
private static void InitializeHighContrastService()
{
if (_highContrastInitialized) return;
_highContrastInitialized = true;
try
{
_highContrastService = new HighContrastService();
_highContrastService.HighContrastChanged += OnHighContrastChanged;
_highContrastService.Initialize();
}
catch
{
// Ignore errors - high contrast is optional
}
}
private static void OnHighContrastChanged(object? sender, HighContrastChangedEventArgs e)
{
// Request a full repaint of the UI
SkiaRenderingEngine.Current?.InvalidateAll();
}
#endregion
#region Accessibility Support (IAccessible)
private static IAccessibilityService? _accessibilityService;
private static bool _accessibilityInitialized;
private string _accessibleId = Guid.NewGuid().ToString();
private List<IAccessible>? _accessibleChildren;
/// <summary>
/// Gets or sets the accessibility name for screen readers.
/// </summary>
public string? SemanticName { get; set; }
/// <summary>
/// Gets or sets the accessibility description for screen readers.
/// </summary>
public string? SemanticDescription { get; set; }
/// <summary>
/// Gets or sets the accessibility hint for screen readers.
/// </summary>
public string? SemanticHint { get; set; }
/// <summary>
/// Gets the accessibility service instance.
/// </summary>
protected static IAccessibilityService? AccessibilityService
{
get
{
InitializeAccessibilityService();
return _accessibilityService;
}
}
private static void InitializeAccessibilityService()
{
if (_accessibilityInitialized) return;
_accessibilityInitialized = true;
try
{
_accessibilityService = AccessibilityServiceFactory.Instance;
_accessibilityService?.Initialize();
}
catch
{
// Ignore errors - accessibility is optional
}
}
/// <summary>
/// Registers this view with the accessibility service.
/// </summary>
protected void RegisterAccessibility()
{
AccessibilityService?.Register(this);
}
/// <summary>
/// Unregisters this view from the accessibility service.
/// </summary>
protected void UnregisterAccessibility()
{
AccessibilityService?.Unregister(this);
}
/// <summary>
/// Announces text to screen readers.
/// </summary>
protected void AnnounceToScreenReader(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
{
AccessibilityService?.Announce(text, priority);
}
// IAccessible implementation
string IAccessible.AccessibleId => _accessibleId;
string IAccessible.AccessibleName => SemanticName ?? GetDefaultAccessibleName();
string IAccessible.AccessibleDescription => SemanticDescription ?? SemanticHint ?? string.Empty;
AccessibleRole IAccessible.Role => GetAccessibleRole();
AccessibleStates IAccessible.States => GetAccessibleStates();
IAccessible? IAccessible.Parent => Parent as IAccessible;
IReadOnlyList<IAccessible> IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
AccessibleRect IAccessible.Bounds => new AccessibleRect(
(int)ScreenBounds.Left,
(int)ScreenBounds.Top,
(int)ScreenBounds.Width,
(int)ScreenBounds.Height);
IReadOnlyList<AccessibleAction> IAccessible.Actions => GetAccessibleActions();
double? IAccessible.Value => GetAccessibleValue();
double? IAccessible.MinValue => GetAccessibleMinValue();
double? IAccessible.MaxValue => GetAccessibleMaxValue();
bool IAccessible.DoAction(string actionName) => DoAccessibleAction(actionName);
bool IAccessible.SetValue(double value) => SetAccessibleValue(value);
/// <summary>
/// Gets the default accessible name based on view content.
/// </summary>
protected virtual string GetDefaultAccessibleName() => string.Empty;
/// <summary>
/// Gets the accessible role for this view.
/// </summary>
protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
/// <summary>
/// Gets the current accessible states.
/// </summary>
protected virtual AccessibleStates GetAccessibleStates()
{
var states = AccessibleStates.None;
if (IsVisible) states |= AccessibleStates.Visible;
if (IsEnabled) states |= AccessibleStates.Enabled;
if (IsFocused) states |= AccessibleStates.Focused;
if (IsFocusable) states |= AccessibleStates.Focusable;
return states;
}
/// <summary>
/// Gets the accessible children of this view.
/// </summary>
protected virtual List<IAccessible> GetAccessibleChildren()
{
var children = new List<IAccessible>();
foreach (var child in Children)
{
if (child is IAccessible accessible)
{
children.Add(accessible);
}
}
return children;
}
/// <summary>
/// Gets the available accessible actions.
/// </summary>
protected virtual IReadOnlyList<AccessibleAction> GetAccessibleActions()
{
return Array.Empty<AccessibleAction>();
}
/// <summary>
/// Performs an accessible action.
/// </summary>
protected virtual bool DoAccessibleAction(string actionName) => false;
/// <summary>
/// Gets the accessible value (for sliders, progress bars, etc.).
/// </summary>
protected virtual double? GetAccessibleValue() => null;
/// <summary>
/// Gets the minimum accessible value.
/// </summary>
protected virtual double? GetAccessibleMinValue() => null;
/// <summary>
/// Gets the maximum accessible value.
/// </summary>
protected virtual double? GetAccessibleMaxValue() => null;
/// <summary>
/// Sets the accessible value.
/// </summary>
protected virtual bool SetAccessibleValue(double value) => false;
#endregion
}

250
Views/SkiaView.Drawing.cs Normal file
View File

@@ -0,0 +1,250 @@
// 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.Shapes;
using Microsoft.Maui.Graphics;
using SkiaSharp;
namespace Microsoft.Maui.Platform;
public abstract partial class SkiaView
{
/// <summary>
/// Draws this view and its children to the canvas.
/// </summary>
public virtual void Draw(SKCanvas canvas)
{
if (!IsVisible || Opacity <= 0)
{
return;
}
canvas.Save();
// Get SKRect for internal rendering
var skBounds = BoundsSK;
// 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 = skBounds.Left + (float)(Bounds.Width * AnchorX);
float anchorAbsY = skBounds.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)
{
canvas.SaveLayer(new SKPaint { Color = SKColors.White.WithAlpha((byte)(Opacity * 255)) });
}
// Draw shadow if set
if (Shadow != null)
{
DrawShadow(canvas, skBounds);
}
// Apply clip geometry if set
if (Clip != null)
{
ApplyClip(canvas, skBounds);
}
// Draw background at absolute bounds
DrawBackground(canvas, skBounds);
// Draw content at absolute bounds
OnDraw(canvas, skBounds);
// Draw children - they draw at their own absolute bounds
foreach (var child in _children)
{
child.Draw(canvas);
}
if (Opacity < 1.0f)
{
canvas.Restore();
}
canvas.Restore();
}
/// <summary>
/// Override to draw custom content.
/// </summary>
protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
{
}
/// <summary>
/// Draws the shadow for this view.
/// </summary>
protected virtual void DrawShadow(SKCanvas canvas, SKRect bounds)
{
if (Shadow == null) return;
var shadowColor = Shadow.Brush is SolidColorBrush scb
? scb.Color.ToSKColor().WithAlpha((byte)(scb.Color.Alpha * 255 * Shadow.Opacity))
: SKColors.Black.WithAlpha((byte)(255 * Shadow.Opacity));
using var shadowPaint = new SKPaint
{
Color = shadowColor,
IsAntialias = true,
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, (float)Shadow.Radius / 2)
};
var shadowBounds = new SKRect(
bounds.Left + (float)Shadow.Offset.X,
bounds.Top + (float)Shadow.Offset.Y,
bounds.Right + (float)Shadow.Offset.X,
bounds.Bottom + (float)Shadow.Offset.Y);
canvas.DrawRect(shadowBounds, shadowPaint);
}
/// <summary>
/// Applies the clip geometry to the canvas.
/// </summary>
protected virtual void ApplyClip(SKCanvas canvas, SKRect bounds)
{
if (Clip == null) return;
// Convert MAUI Geometry to SkiaSharp path
var path = ConvertGeometryToPath(Clip, bounds);
if (path != null)
{
canvas.ClipPath(path);
}
}
/// <summary>
/// Converts a MAUI Geometry to a SkiaSharp path.
/// </summary>
private SKPath? ConvertGeometryToPath(Geometry geometry, SKRect bounds)
{
var path = new SKPath();
if (geometry is RectangleGeometry rect)
{
var r = rect.Rect;
path.AddRect(new SKRect(
bounds.Left + (float)r.Left,
bounds.Top + (float)r.Top,
bounds.Left + (float)r.Right,
bounds.Top + (float)r.Bottom));
}
else if (geometry is EllipseGeometry ellipse)
{
path.AddOval(new SKRect(
bounds.Left + (float)(ellipse.Center.X - ellipse.RadiusX),
bounds.Top + (float)(ellipse.Center.Y - ellipse.RadiusY),
bounds.Left + (float)(ellipse.Center.X + ellipse.RadiusX),
bounds.Top + (float)(ellipse.Center.Y + ellipse.RadiusY)));
}
else if (geometry is RoundRectangleGeometry roundRect)
{
var r = roundRect.Rect;
var cr = roundRect.CornerRadius;
var skRect = new SKRect(
bounds.Left + (float)r.Left,
bounds.Top + (float)r.Top,
bounds.Left + (float)r.Right,
bounds.Top + (float)r.Bottom);
var skRoundRect = new SKRoundRect();
skRoundRect.SetRectRadii(skRect, new[]
{
new SKPoint((float)cr.TopLeft, (float)cr.TopLeft),
new SKPoint((float)cr.TopRight, (float)cr.TopRight),
new SKPoint((float)cr.BottomRight, (float)cr.BottomRight),
new SKPoint((float)cr.BottomLeft, (float)cr.BottomLeft)
});
path.AddRoundRect(skRoundRect);
}
// Add more geometry types as needed
return path;
}
/// <summary>
/// Draws the background (color or brush) for this view.
/// </summary>
protected virtual void DrawBackground(SKCanvas canvas, SKRect bounds)
{
// First try to use Background brush
if (Background != null)
{
using var paint = new SKPaint { IsAntialias = true };
if (Background is SolidColorBrush scb)
{
paint.Color = scb.Color.ToSKColor();
canvas.DrawRect(bounds, paint);
}
else if (Background is LinearGradientBrush lgb)
{
var start = new SKPoint(
bounds.Left + (float)(lgb.StartPoint.X * bounds.Width),
bounds.Top + (float)(lgb.StartPoint.Y * bounds.Height));
var end = new SKPoint(
bounds.Left + (float)(lgb.EndPoint.X * bounds.Width),
bounds.Top + (float)(lgb.EndPoint.Y * bounds.Height));
var colors = lgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
var positions = lgb.GradientStops.Select(s => s.Offset).ToArray();
paint.Shader = SKShader.CreateLinearGradient(start, end, colors, positions, SKShaderTileMode.Clamp);
canvas.DrawRect(bounds, paint);
}
else if (Background is RadialGradientBrush rgb)
{
var center = new SKPoint(
bounds.Left + (float)(rgb.Center.X * bounds.Width),
bounds.Top + (float)(rgb.Center.Y * bounds.Height));
var radius = (float)(rgb.Radius * Math.Max(bounds.Width, bounds.Height));
var colors = rgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
var positions = rgb.GradientStops.Select(s => s.Offset).ToArray();
paint.Shader = SKShader.CreateRadialGradient(center, radius, colors, positions, SKShaderTileMode.Clamp);
canvas.DrawRect(bounds, paint);
}
}
// Fall back to BackgroundColor (skip if transparent)
else if (_backgroundColorSK.Alpha > 0)
{
using var paint = new SKPaint { Color = _backgroundColorSK };
canvas.DrawRect(bounds, paint);
}
}
}

105
Views/SkiaView.Input.cs Normal file
View File

@@ -0,0 +1,105 @@
// 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.Graphics;
using Microsoft.Maui.Platform.Linux.Handlers;
using Microsoft.Maui.Platform.Linux.Services;
namespace Microsoft.Maui.Platform;
public abstract partial class SkiaView
{
#region Input Events
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)
{
DiagnosticLog.Debug("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) { }
public virtual void OnTextInput(TextInputEventArgs e) { }
public virtual void OnFocusGained()
{
IsFocused = true;
Invalidate();
}
public virtual void OnFocusLost()
{
IsFocused = false;
Invalidate();
}
#endregion
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Clean up gesture tracking to prevent memory leaks
if (MauiView != null)
{
GestureManager.CleanupView(MauiView);
}
foreach (var child in _children)
{
child.Dispose();
}
_children.Clear();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}

View File

@@ -19,308 +19,8 @@ namespace Microsoft.Maui.Platform;
/// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager.
/// Implements IAccessible for screen reader support.
/// </summary>
public abstract class SkiaView : BindableObject, IDisposable, IAccessible
public abstract partial class SkiaView : BindableObject, IDisposable, IAccessible
{
// Popup overlay system for dropdowns, calendars, etc.
private static readonly List<(SkiaView Owner, Action<SKCanvas> Draw)> _popupOverlays = new();
public static void RegisterPopupOverlay(SkiaView owner, Action<SKCanvas> drawAction)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
_popupOverlays.Add((owner, drawAction));
}
public static void UnregisterPopupOverlay(SkiaView owner)
{
_popupOverlays.RemoveAll(p => p.Owner == owner);
}
public static void DrawPopupOverlays(SKCanvas canvas)
{
// Restore canvas to clean state for overlay drawing
// Save count tells us how many unmatched Saves there are
while (canvas.SaveCount > 1)
{
canvas.Restore();
}
foreach (var (_, draw) in _popupOverlays)
{
canvas.Save();
draw(canvas);
canvas.Restore();
}
}
/// <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 High Contrast Support
private static HighContrastService? _highContrastService;
private static bool _highContrastInitialized;
/// <summary>
/// Gets whether high contrast mode is enabled.
/// </summary>
public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
/// <summary>
/// Gets the current high contrast colors, or default colors if not in high contrast mode.
/// </summary>
public static HighContrastColors GetHighContrastColors()
{
InitializeHighContrastService();
return _highContrastService?.GetColors() ?? new HighContrastColors
{
Background = SkiaTheme.BackgroundWhiteSK,
Foreground = SkiaTheme.TextPrimarySK,
Accent = SkiaTheme.PrimarySK,
Border = SkiaTheme.BorderMediumSK,
Error = SkiaTheme.ErrorSK,
Success = SkiaTheme.SuccessSK,
Warning = SkiaTheme.WarningSK,
Link = SkiaTheme.TextLinkSK,
LinkVisited = SkiaTheme.TextLinkVisitedSK,
Selection = SkiaTheme.PrimarySK,
SelectionText = SkiaTheme.BackgroundWhiteSK,
DisabledText = SkiaTheme.TextDisabledSK,
DisabledBackground = SkiaTheme.BackgroundDisabledSK
};
}
private static void InitializeHighContrastService()
{
if (_highContrastInitialized) return;
_highContrastInitialized = true;
try
{
_highContrastService = new HighContrastService();
_highContrastService.HighContrastChanged += OnHighContrastChanged;
_highContrastService.Initialize();
}
catch
{
// Ignore errors - high contrast is optional
}
}
private static void OnHighContrastChanged(object? sender, HighContrastChangedEventArgs e)
{
// Request a full repaint of the UI
SkiaRenderingEngine.Current?.InvalidateAll();
}
#endregion
#region Accessibility Support (IAccessible)
private static IAccessibilityService? _accessibilityService;
private static bool _accessibilityInitialized;
private string _accessibleId = Guid.NewGuid().ToString();
private List<IAccessible>? _accessibleChildren;
/// <summary>
/// Gets or sets the accessibility name for screen readers.
/// </summary>
public string? SemanticName { get; set; }
/// <summary>
/// Gets or sets the accessibility description for screen readers.
/// </summary>
public string? SemanticDescription { get; set; }
/// <summary>
/// Gets or sets the accessibility hint for screen readers.
/// </summary>
public string? SemanticHint { get; set; }
/// <summary>
/// Gets the accessibility service instance.
/// </summary>
protected static IAccessibilityService? AccessibilityService
{
get
{
InitializeAccessibilityService();
return _accessibilityService;
}
}
private static void InitializeAccessibilityService()
{
if (_accessibilityInitialized) return;
_accessibilityInitialized = true;
try
{
_accessibilityService = AccessibilityServiceFactory.Instance;
_accessibilityService?.Initialize();
}
catch
{
// Ignore errors - accessibility is optional
}
}
/// <summary>
/// Registers this view with the accessibility service.
/// </summary>
protected void RegisterAccessibility()
{
AccessibilityService?.Register(this);
}
/// <summary>
/// Unregisters this view from the accessibility service.
/// </summary>
protected void UnregisterAccessibility()
{
AccessibilityService?.Unregister(this);
}
/// <summary>
/// Announces text to screen readers.
/// </summary>
protected void AnnounceToScreenReader(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
{
AccessibilityService?.Announce(text, priority);
}
// IAccessible implementation
string IAccessible.AccessibleId => _accessibleId;
string IAccessible.AccessibleName => SemanticName ?? GetDefaultAccessibleName();
string IAccessible.AccessibleDescription => SemanticDescription ?? SemanticHint ?? string.Empty;
AccessibleRole IAccessible.Role => GetAccessibleRole();
AccessibleStates IAccessible.States => GetAccessibleStates();
IAccessible? IAccessible.Parent => Parent as IAccessible;
IReadOnlyList<IAccessible> IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
AccessibleRect IAccessible.Bounds => new AccessibleRect(
(int)ScreenBounds.Left,
(int)ScreenBounds.Top,
(int)ScreenBounds.Width,
(int)ScreenBounds.Height);
IReadOnlyList<AccessibleAction> IAccessible.Actions => GetAccessibleActions();
double? IAccessible.Value => GetAccessibleValue();
double? IAccessible.MinValue => GetAccessibleMinValue();
double? IAccessible.MaxValue => GetAccessibleMaxValue();
bool IAccessible.DoAction(string actionName) => DoAccessibleAction(actionName);
bool IAccessible.SetValue(double value) => SetAccessibleValue(value);
/// <summary>
/// Gets the default accessible name based on view content.
/// </summary>
protected virtual string GetDefaultAccessibleName() => string.Empty;
/// <summary>
/// Gets the accessible role for this view.
/// </summary>
protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
/// <summary>
/// Gets the current accessible states.
/// </summary>
protected virtual AccessibleStates GetAccessibleStates()
{
var states = AccessibleStates.None;
if (IsVisible) states |= AccessibleStates.Visible;
if (IsEnabled) states |= AccessibleStates.Enabled;
if (IsFocused) states |= AccessibleStates.Focused;
if (IsFocusable) states |= AccessibleStates.Focusable;
return states;
}
/// <summary>
/// Gets the accessible children of this view.
/// </summary>
protected virtual List<IAccessible> GetAccessibleChildren()
{
var children = new List<IAccessible>();
foreach (var child in Children)
{
if (child is IAccessible accessible)
{
children.Add(accessible);
}
}
return children;
}
/// <summary>
/// Gets the available accessible actions.
/// </summary>
protected virtual IReadOnlyList<AccessibleAction> GetAccessibleActions()
{
return Array.Empty<AccessibleAction>();
}
/// <summary>
/// Performs an accessible action.
/// </summary>
protected virtual bool DoAccessibleAction(string actionName) => false;
/// <summary>
/// Gets the accessible value (for sliders, progress bars, etc.).
/// </summary>
protected virtual double? GetAccessibleValue() => null;
/// <summary>
/// Gets the minimum accessible value.
/// </summary>
protected virtual double? GetAccessibleMinValue() => null;
/// <summary>
/// Gets the maximum accessible value.
/// </summary>
protected virtual double? GetAccessibleMaxValue() => null;
/// <summary>
/// Sets the accessible value.
/// </summary>
protected virtual bool SetAccessibleValue(double value) => false;
#endregion
#region BindableProperties
/// <summary>
@@ -1324,244 +1024,6 @@ public abstract class SkiaView : BindableObject, IDisposable, IAccessible
Invalidate();
}
/// <summary>
/// Draws this view and its children to the canvas.
/// </summary>
public virtual void Draw(SKCanvas canvas)
{
if (!IsVisible || Opacity <= 0)
{
return;
}
canvas.Save();
// Get SKRect for internal rendering
var skBounds = BoundsSK;
// 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 = skBounds.Left + (float)(Bounds.Width * AnchorX);
float anchorAbsY = skBounds.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)
{
canvas.SaveLayer(new SKPaint { Color = SKColors.White.WithAlpha((byte)(Opacity * 255)) });
}
// Draw shadow if set
if (Shadow != null)
{
DrawShadow(canvas, skBounds);
}
// Apply clip geometry if set
if (Clip != null)
{
ApplyClip(canvas, skBounds);
}
// Draw background at absolute bounds
DrawBackground(canvas, skBounds);
// Draw content at absolute bounds
OnDraw(canvas, skBounds);
// Draw children - they draw at their own absolute bounds
foreach (var child in _children)
{
child.Draw(canvas);
}
if (Opacity < 1.0f)
{
canvas.Restore();
}
canvas.Restore();
}
/// <summary>
/// Override to draw custom content.
/// </summary>
protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
{
}
/// <summary>
/// Draws the shadow for this view.
/// </summary>
protected virtual void DrawShadow(SKCanvas canvas, SKRect bounds)
{
if (Shadow == null) return;
var shadowColor = Shadow.Brush is SolidColorBrush scb
? scb.Color.ToSKColor().WithAlpha((byte)(scb.Color.Alpha * 255 * Shadow.Opacity))
: SKColors.Black.WithAlpha((byte)(255 * Shadow.Opacity));
using var shadowPaint = new SKPaint
{
Color = shadowColor,
IsAntialias = true,
MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, (float)Shadow.Radius / 2)
};
var shadowBounds = new SKRect(
bounds.Left + (float)Shadow.Offset.X,
bounds.Top + (float)Shadow.Offset.Y,
bounds.Right + (float)Shadow.Offset.X,
bounds.Bottom + (float)Shadow.Offset.Y);
canvas.DrawRect(shadowBounds, shadowPaint);
}
/// <summary>
/// Applies the clip geometry to the canvas.
/// </summary>
protected virtual void ApplyClip(SKCanvas canvas, SKRect bounds)
{
if (Clip == null) return;
// Convert MAUI Geometry to SkiaSharp path
var path = ConvertGeometryToPath(Clip, bounds);
if (path != null)
{
canvas.ClipPath(path);
}
}
/// <summary>
/// Converts a MAUI Geometry to a SkiaSharp path.
/// </summary>
private SKPath? ConvertGeometryToPath(Geometry geometry, SKRect bounds)
{
var path = new SKPath();
if (geometry is RectangleGeometry rect)
{
var r = rect.Rect;
path.AddRect(new SKRect(
bounds.Left + (float)r.Left,
bounds.Top + (float)r.Top,
bounds.Left + (float)r.Right,
bounds.Top + (float)r.Bottom));
}
else if (geometry is EllipseGeometry ellipse)
{
path.AddOval(new SKRect(
bounds.Left + (float)(ellipse.Center.X - ellipse.RadiusX),
bounds.Top + (float)(ellipse.Center.Y - ellipse.RadiusY),
bounds.Left + (float)(ellipse.Center.X + ellipse.RadiusX),
bounds.Top + (float)(ellipse.Center.Y + ellipse.RadiusY)));
}
else if (geometry is RoundRectangleGeometry roundRect)
{
var r = roundRect.Rect;
var cr = roundRect.CornerRadius;
var skRect = new SKRect(
bounds.Left + (float)r.Left,
bounds.Top + (float)r.Top,
bounds.Left + (float)r.Right,
bounds.Top + (float)r.Bottom);
var skRoundRect = new SKRoundRect();
skRoundRect.SetRectRadii(skRect, new[]
{
new SKPoint((float)cr.TopLeft, (float)cr.TopLeft),
new SKPoint((float)cr.TopRight, (float)cr.TopRight),
new SKPoint((float)cr.BottomRight, (float)cr.BottomRight),
new SKPoint((float)cr.BottomLeft, (float)cr.BottomLeft)
});
path.AddRoundRect(skRoundRect);
}
// Add more geometry types as needed
return path;
}
/// <summary>
/// Draws the background (color or brush) for this view.
/// </summary>
protected virtual void DrawBackground(SKCanvas canvas, SKRect bounds)
{
// First try to use Background brush
if (Background != null)
{
using var paint = new SKPaint { IsAntialias = true };
if (Background is SolidColorBrush scb)
{
paint.Color = scb.Color.ToSKColor();
canvas.DrawRect(bounds, paint);
}
else if (Background is LinearGradientBrush lgb)
{
var start = new SKPoint(
bounds.Left + (float)(lgb.StartPoint.X * bounds.Width),
bounds.Top + (float)(lgb.StartPoint.Y * bounds.Height));
var end = new SKPoint(
bounds.Left + (float)(lgb.EndPoint.X * bounds.Width),
bounds.Top + (float)(lgb.EndPoint.Y * bounds.Height));
var colors = lgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
var positions = lgb.GradientStops.Select(s => s.Offset).ToArray();
paint.Shader = SKShader.CreateLinearGradient(start, end, colors, positions, SKShaderTileMode.Clamp);
canvas.DrawRect(bounds, paint);
}
else if (Background is RadialGradientBrush rgb)
{
var center = new SKPoint(
bounds.Left + (float)(rgb.Center.X * bounds.Width),
bounds.Top + (float)(rgb.Center.Y * bounds.Height));
var radius = (float)(rgb.Radius * Math.Max(bounds.Width, bounds.Height));
var colors = rgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
var positions = rgb.GradientStops.Select(s => s.Offset).ToArray();
paint.Shader = SKShader.CreateRadialGradient(center, radius, colors, positions, SKShaderTileMode.Clamp);
canvas.DrawRect(bounds, paint);
}
}
// Fall back to BackgroundColor (skip if transparent)
else if (_backgroundColorSK.Alpha > 0)
{
using var paint = new SKPaint { Color = _backgroundColorSK };
canvas.DrawRect(bounds, paint);
}
}
/// <summary>
/// Called when the bounds change.
/// </summary>
@@ -1645,100 +1107,6 @@ public abstract class SkiaView : BindableObject, IDisposable, IAccessible
return this;
}
#region Input Events
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)
{
DiagnosticLog.Debug("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) { }
public virtual void OnTextInput(TextInputEventArgs e) { }
public virtual void OnFocusGained()
{
IsFocused = true;
Invalidate();
}
public virtual void OnFocusLost()
{
IsFocused = false;
Invalidate();
}
#endregion
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Clean up gesture tracking to prevent memory leaks
if (MauiView != null)
{
GestureManager.CleanupView(MauiView);
}
foreach (var child in _children)
{
child.Dispose();
}
_children.Clear();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
/// <summary>