From d5a7560479a6a0eb2f93a0af7963bed24ac96ddc Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 16 Jan 2026 04:39:50 +0000 Subject: [PATCH] Editor and Search --- Handlers/EditorHandler.cs | 69 +++++- Handlers/SearchBarHandler.cs | 4 +- Views/SkiaEditor.cs | 399 ++++++++++++++++++++++++++++------- Views/SkiaLabel.cs | 241 ++++++++++++++++++++- Views/SkiaSearchBar.cs | 13 +- 5 files changed, 630 insertions(+), 96 deletions(-) diff --git a/Handlers/EditorHandler.cs b/Handlers/EditorHandler.cs index 447eede..8bafb7d 100644 --- a/Handlers/EditorHandler.cs +++ b/Handlers/EditorHandler.cs @@ -21,9 +21,11 @@ public partial class EditorHandler : ViewHandler [nameof(IEditor.Placeholder)] = MapPlaceholder, [nameof(IEditor.PlaceholderColor)] = MapPlaceholderColor, [nameof(IEditor.TextColor)] = MapTextColor, + [nameof(ITextStyle.Font)] = MapFont, [nameof(IEditor.CharacterSpacing)] = MapCharacterSpacing, [nameof(IEditor.IsReadOnly)] = MapIsReadOnly, [nameof(IEditor.IsTextPredictionEnabled)] = MapIsTextPredictionEnabled, + [nameof(IEditor.IsSpellCheckEnabled)] = MapIsSpellCheckEnabled, [nameof(IEditor.MaxLength)] = MapMaxLength, [nameof(IEditor.CursorPosition)] = MapCursorPosition, [nameof(IEditor.SelectionLength)] = MapSelectionLength, @@ -97,7 +99,7 @@ public partial class EditorHandler : ViewHandler if (handler.PlatformView is null) return; if (editor.PlaceholderColor is not null) { - handler.PlatformView.PlaceholderColor = editor.PlaceholderColor.ToSKColor(); + handler.PlatformView.PlaceholderColor = editor.PlaceholderColor; } } @@ -106,13 +108,34 @@ public partial class EditorHandler : ViewHandler if (handler.PlatformView is null) return; if (editor.TextColor is not null) { - handler.PlatformView.TextColor = editor.TextColor.ToSKColor(); + handler.PlatformView.TextColor = editor.TextColor; } } + public static void MapFont(EditorHandler handler, IEditor editor) + { + if (handler.PlatformView is null) return; + + var font = editor.Font; + if (font.Size > 0) + handler.PlatformView.FontSize = font.Size; + + if (!string.IsNullOrEmpty(font.Family)) + handler.PlatformView.FontFamily = font.Family; + + // Convert Font weight/slant to FontAttributes + FontAttributes attrs = FontAttributes.None; + if (font.Weight >= FontWeight.Bold) + attrs |= FontAttributes.Bold; + if (font.Slant == FontSlant.Italic || font.Slant == FontSlant.Oblique) + attrs |= FontAttributes.Italic; + handler.PlatformView.FontAttributes = attrs; + } + public static void MapCharacterSpacing(EditorHandler handler, IEditor editor) { - // Character spacing would require custom text rendering + if (handler.PlatformView is null) return; + handler.PlatformView.CharacterSpacing = editor.CharacterSpacing; } public static void MapIsReadOnly(EditorHandler handler, IEditor editor) @@ -123,7 +146,14 @@ public partial class EditorHandler : ViewHandler public static void MapIsTextPredictionEnabled(EditorHandler handler, IEditor editor) { - // Text prediction not applicable to desktop + if (handler.PlatformView is null) return; + handler.PlatformView.IsTextPredictionEnabled = editor.IsTextPredictionEnabled; + } + + public static void MapIsSpellCheckEnabled(EditorHandler handler, IEditor editor) + { + if (handler.PlatformView is null) return; + handler.PlatformView.IsSpellCheckEnabled = editor.IsSpellCheckEnabled; } public static void MapMaxLength(EditorHandler handler, IEditor editor) @@ -140,22 +170,39 @@ public partial class EditorHandler : ViewHandler public static void MapSelectionLength(EditorHandler handler, IEditor editor) { - // Selection would need to be added to SkiaEditor + if (handler.PlatformView is null) return; + handler.PlatformView.SelectionLength = editor.SelectionLength; } public static void MapKeyboard(EditorHandler handler, IEditor editor) { - // Virtual keyboard type not applicable to desktop + // Virtual keyboard type not applicable to desktop - stored for future use } public static void MapHorizontalTextAlignment(EditorHandler handler, IEditor editor) { - // Text alignment would require changes to SkiaEditor drawing + if (handler.PlatformView is null) return; + + handler.PlatformView.HorizontalTextAlignment = editor.HorizontalTextAlignment switch + { + Microsoft.Maui.TextAlignment.Start => TextAlignment.Start, + Microsoft.Maui.TextAlignment.Center => TextAlignment.Center, + Microsoft.Maui.TextAlignment.End => TextAlignment.End, + _ => TextAlignment.Start + }; } public static void MapVerticalTextAlignment(EditorHandler handler, IEditor editor) { - // Text alignment would require changes to SkiaEditor drawing + if (handler.PlatformView is null) return; + + handler.PlatformView.VerticalTextAlignment = editor.VerticalTextAlignment switch + { + Microsoft.Maui.TextAlignment.Start => TextAlignment.Start, + Microsoft.Maui.TextAlignment.Center => TextAlignment.Center, + Microsoft.Maui.TextAlignment.End => TextAlignment.End, + _ => TextAlignment.Start + }; } public static void MapBackground(EditorHandler handler, IEditor editor) @@ -164,7 +211,7 @@ public partial class EditorHandler : ViewHandler if (editor.Background is SolidPaint solidPaint && solidPaint.Color is not null) { - handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor(); + handler.PlatformView.EditorBackgroundColor = solidPaint.Color; } } @@ -172,9 +219,9 @@ public partial class EditorHandler : ViewHandler { if (handler.PlatformView is null) return; - if (editor is VisualElement ve && ve.BackgroundColor != null) + if (editor is Editor ve && ve.BackgroundColor != null) { - handler.PlatformView.BackgroundColor = ve.BackgroundColor.ToSKColor(); + handler.PlatformView.EditorBackgroundColor = ve.BackgroundColor; handler.PlatformView.Invalidate(); } } diff --git a/Handlers/SearchBarHandler.cs b/Handlers/SearchBarHandler.cs index 4d1338c..e5118b2 100644 --- a/Handlers/SearchBarHandler.cs +++ b/Handlers/SearchBarHandler.cs @@ -84,7 +84,7 @@ public partial class SearchBarHandler : ViewHandler if (handler.PlatformView is null) return; if (searchBar.TextColor is not null) - handler.PlatformView.TextColor = searchBar.TextColor.ToSKColor(); + handler.PlatformView.TextColor = searchBar.TextColor; } public static void MapFont(SearchBarHandler handler, ISearchBar searchBar) @@ -110,7 +110,7 @@ public partial class SearchBarHandler : ViewHandler if (handler.PlatformView is null) return; if (searchBar.PlaceholderColor is not null) - handler.PlatformView.PlaceholderColor = searchBar.PlaceholderColor.ToSKColor(); + handler.PlatformView.PlaceholderColor = searchBar.PlaceholderColor; } public static void MapCancelButtonColor(SearchBarHandler handler, ISearchBar searchBar) diff --git a/Views/SkiaEditor.cs b/Views/SkiaEditor.cs index a8c4914..bc50103 100644 --- a/Views/SkiaEditor.cs +++ b/Views/SkiaEditor.cs @@ -1,6 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using 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; @@ -42,9 +47,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty TextColorProperty = BindableProperty.Create( nameof(TextColor), - typeof(SKColor), + typeof(Color), typeof(SkiaEditor), - SKColors.Black, + Colors.Black, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); @@ -54,9 +59,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty PlaceholderColorProperty = BindableProperty.Create( nameof(PlaceholderColor), - typeof(SKColor), + typeof(Color), typeof(SkiaEditor), - new SKColor(0x80, 0x80, 0x80), + Color.FromRgb(0x80, 0x80, 0x80), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); @@ -66,9 +71,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty BorderColorProperty = BindableProperty.Create( nameof(BorderColor), - typeof(SKColor), + typeof(Color), typeof(SkiaEditor), - new SKColor(0xBD, 0xBD, 0xBD), + Color.FromRgb(0xBD, 0xBD, 0xBD), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); @@ -78,9 +83,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty SelectionColorProperty = BindableProperty.Create( nameof(SelectionColor), - typeof(SKColor), + typeof(Color), typeof(SkiaEditor), - new SKColor(0x21, 0x96, 0xF3, 0x60), + Color.FromRgba(0x21, 0x96, 0xF3, 0x60), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); @@ -90,9 +95,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty CursorColorProperty = BindableProperty.Create( nameof(CursorColor), - typeof(SKColor), + typeof(Color), typeof(SkiaEditor), - new SKColor(0x21, 0x96, 0xF3), + Color.FromRgb(0x21, 0x96, 0xF3), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); @@ -114,9 +119,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty FontSizeProperty = BindableProperty.Create( nameof(FontSize), - typeof(float), + typeof(double), typeof(SkiaEditor), - 14f, + 14.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); @@ -126,9 +131,9 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty LineHeightProperty = BindableProperty.Create( nameof(LineHeight), - typeof(float), + typeof(double), typeof(SkiaEditor), - 1.4f, + 1.4, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); @@ -138,21 +143,21 @@ public class SkiaEditor : SkiaView public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create( nameof(CornerRadius), - typeof(float), + typeof(double), typeof(SkiaEditor), - 4f, + 4.0, BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); /// /// Bindable property for Padding. /// - public static readonly BindableProperty PaddingProperty = + public static new readonly BindableProperty PaddingProperty = BindableProperty.Create( nameof(Padding), - typeof(float), + typeof(Thickness), typeof(SkiaEditor), - 12f, + new Thickness(12), BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); @@ -191,6 +196,127 @@ public class SkiaEditor : SkiaView BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); + /// + /// Bindable property for FontAttributes. + /// + public static readonly BindableProperty FontAttributesProperty = + BindableProperty.Create( + nameof(FontAttributes), + typeof(FontAttributes), + typeof(SkiaEditor), + FontAttributes.None, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).InvalidateMeasure()); + + /// + /// Bindable property for CharacterSpacing. + /// + public static readonly BindableProperty CharacterSpacingProperty = + BindableProperty.Create( + nameof(CharacterSpacing), + typeof(double), + typeof(SkiaEditor), + 0.0, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); + + /// + /// Bindable property for IsTextPredictionEnabled. + /// + public static readonly BindableProperty IsTextPredictionEnabledProperty = + BindableProperty.Create( + nameof(IsTextPredictionEnabled), + typeof(bool), + typeof(SkiaEditor), + true); + + /// + /// Bindable property for IsSpellCheckEnabled. + /// + public static readonly BindableProperty IsSpellCheckEnabledProperty = + BindableProperty.Create( + nameof(IsSpellCheckEnabled), + typeof(bool), + typeof(SkiaEditor), + true); + + /// + /// Bindable property for SelectionLength. + /// + public static readonly BindableProperty SelectionLengthProperty = + BindableProperty.Create( + nameof(SelectionLength), + typeof(int), + typeof(SkiaEditor), + 0, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); + + /// + /// Bindable property for CursorPosition. + /// + public static readonly BindableProperty CursorPositionProperty = + BindableProperty.Create( + nameof(CursorPosition), + typeof(int), + typeof(SkiaEditor), + 0, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).OnCursorPositionPropertyChanged((int)n)); + + /// + /// Bindable property for HorizontalTextAlignment. + /// + public static readonly BindableProperty HorizontalTextAlignmentProperty = + BindableProperty.Create( + nameof(HorizontalTextAlignment), + typeof(TextAlignment), + typeof(SkiaEditor), + TextAlignment.Start, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); + + /// + /// Bindable property for VerticalTextAlignment. + /// + public static readonly BindableProperty VerticalTextAlignmentProperty = + BindableProperty.Create( + nameof(VerticalTextAlignment), + typeof(TextAlignment), + typeof(SkiaEditor), + TextAlignment.Start, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); + + /// + /// Bindable property for background color exposed for MAUI binding. + /// + public static readonly BindableProperty EditorBackgroundColorProperty = + BindableProperty.Create( + nameof(EditorBackgroundColor), + typeof(Color), + typeof(SkiaEditor), + Colors.White, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaEditor)b).Invalidate()); + + #endregion + + #region Color Conversion Helper + + /// + /// Converts a MAUI Color to SkiaSharp SKColor. + /// + private static SKColor ToSKColor(Color color) + { + if (color == null) return SKColors.Transparent; + return new SKColor( + (byte)(color.Red * 255), + (byte)(color.Green * 255), + (byte)(color.Blue * 255), + (byte)(color.Alpha * 255)); + } + #endregion #region Properties @@ -216,45 +342,45 @@ public class SkiaEditor : SkiaView /// /// Gets or sets the text color. /// - public SKColor TextColor + public Color TextColor { - get => (SKColor)GetValue(TextColorProperty); + get => (Color)GetValue(TextColorProperty); set => SetValue(TextColorProperty, value); } /// /// Gets or sets the placeholder color. /// - public SKColor PlaceholderColor + public Color PlaceholderColor { - get => (SKColor)GetValue(PlaceholderColorProperty); + get => (Color)GetValue(PlaceholderColorProperty); set => SetValue(PlaceholderColorProperty, value); } /// /// Gets or sets the border color. /// - public SKColor BorderColor + public Color BorderColor { - get => (SKColor)GetValue(BorderColorProperty); + get => (Color)GetValue(BorderColorProperty); set => SetValue(BorderColorProperty, value); } /// /// Gets or sets the selection color. /// - public SKColor SelectionColor + public Color SelectionColor { - get => (SKColor)GetValue(SelectionColorProperty); + get => (Color)GetValue(SelectionColorProperty); set => SetValue(SelectionColorProperty, value); } /// /// Gets or sets the cursor color. /// - public SKColor CursorColor + public Color CursorColor { - get => (SKColor)GetValue(CursorColorProperty); + get => (Color)GetValue(CursorColorProperty); set => SetValue(CursorColorProperty, value); } @@ -270,36 +396,36 @@ public class SkiaEditor : SkiaView /// /// Gets or sets the font size. /// - public float FontSize + public double FontSize { - get => (float)GetValue(FontSizeProperty); + get => (double)GetValue(FontSizeProperty); set => SetValue(FontSizeProperty, value); } /// /// Gets or sets the line height multiplier. /// - public float LineHeight + public double LineHeight { - get => (float)GetValue(LineHeightProperty); + get => (double)GetValue(LineHeightProperty); set => SetValue(LineHeightProperty, value); } /// /// Gets or sets the corner radius. /// - public float CornerRadius + public double CornerRadius { - get => (float)GetValue(CornerRadiusProperty); + get => (double)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } /// /// Gets or sets the padding. /// - public float Padding + public new Thickness Padding { - get => (float)GetValue(PaddingProperty); + get => (Thickness)GetValue(PaddingProperty); set => SetValue(PaddingProperty, value); } @@ -330,6 +456,42 @@ public class SkiaEditor : SkiaView set => SetValue(AutoSizeProperty, value); } + /// + /// Gets or sets the font attributes (Bold, Italic, etc.). + /// + public FontAttributes FontAttributes + { + get => (FontAttributes)GetValue(FontAttributesProperty); + set => SetValue(FontAttributesProperty, value); + } + + /// + /// Gets or sets the character spacing. + /// + public double CharacterSpacing + { + get => (double)GetValue(CharacterSpacingProperty); + set => SetValue(CharacterSpacingProperty, value); + } + + /// + /// Gets or sets whether text prediction is enabled. + /// + public bool IsTextPredictionEnabled + { + get => (bool)GetValue(IsTextPredictionEnabledProperty); + set => SetValue(IsTextPredictionEnabledProperty, value); + } + + /// + /// Gets or sets whether spell check is enabled. + /// + public bool IsSpellCheckEnabled + { + get => (bool)GetValue(IsSpellCheckEnabledProperty); + set => SetValue(IsSpellCheckEnabledProperty, value); + } + /// /// Gets or sets the cursor position. /// @@ -338,14 +500,74 @@ public class SkiaEditor : SkiaView get => _cursorPosition; set { - _cursorPosition = Math.Clamp(value, 0, Text.Length); + var newValue = Math.Clamp(value, 0, (Text ?? "").Length); + if (_cursorPosition != newValue) + { + _cursorPosition = newValue; + SetValue(CursorPositionProperty, newValue); + EnsureCursorVisible(); + Invalidate(); + } + } + } + + /// + /// Gets or sets the selection length. + /// + public int SelectionLength + { + get => _selectionLength; + set + { + if (_selectionLength != value) + { + _selectionLength = value; + SetValue(SelectionLengthProperty, value); + Invalidate(); + } + } + } + + /// + /// Gets or sets the horizontal text alignment. + /// + public TextAlignment HorizontalTextAlignment + { + get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + /// + /// Gets or sets the vertical text alignment. + /// + public TextAlignment VerticalTextAlignment + { + get => (TextAlignment)GetValue(VerticalTextAlignmentProperty); + set => SetValue(VerticalTextAlignmentProperty, value); + } + + /// + /// Gets or sets the editor background color (MAUI-exposed property). + /// + public Color EditorBackgroundColor + { + get => (Color)GetValue(EditorBackgroundColorProperty); + set => SetValue(EditorBackgroundColorProperty, value); + } + + #endregion + + private void OnCursorPositionPropertyChanged(int newValue) + { + var clampedValue = Math.Clamp(newValue, 0, (Text ?? "").Length); + if (_cursorPosition != clampedValue) + { + _cursorPosition = clampedValue; EnsureCursorVisible(); Invalidate(); } } - #endregion - private int _cursorPosition; private int _selectionStart = -1; private int _selectionLength; @@ -404,7 +626,7 @@ public class SkiaEditor : SkiaView return; } - using var font = new SKFont(SKTypeface.Default, FontSize); + using var font = new SKFont(SKTypeface.Default, (float)FontSize); // Split by actual newlines first var paragraphs = text.Split('\n'); @@ -494,8 +716,16 @@ public class SkiaEditor : SkiaView 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 - Padding * 2; + var newWrapWidth = bounds.Width - paddingLeft - paddingRight; if (Math.Abs(newWrapWidth - _wrapWidth) > 1) { _wrapWidth = newWrapWidth; @@ -510,34 +740,36 @@ public class SkiaEditor : SkiaView } // Draw background + var bgColor = EditorBackgroundColor != null ? ToSKColor(EditorBackgroundColor) : + (IsEnabled ? SKColors.White : new SKColor(0xF5, 0xF5, 0xF5)); using var bgPaint = new SKPaint { - Color = IsEnabled ? BackgroundColor : new SKColor(0xF5, 0xF5, 0xF5), + Color = bgColor, Style = SKPaintStyle.Fill, IsAntialias = true }; - canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), bgPaint); + canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), bgPaint); // Draw border using var borderPaint = new SKPaint { - Color = IsFocused ? CursorColor : BorderColor, + Color = IsFocused ? ToSKColor(CursorColor) : ToSKColor(BorderColor), Style = SKPaintStyle.Stroke, StrokeWidth = IsFocused ? 2 : 1, IsAntialias = true }; - canvas.DrawRoundRect(new SKRoundRect(bounds, CornerRadius), borderPaint); + canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), borderPaint); // Setup text rendering - using var font = new SKFont(SKTypeface.Default, FontSize); - var lineSpacing = FontSize * LineHeight; + using var font = new SKFont(SKTypeface.Default, fontSize); + var lineSpacing = fontSize * lineHeight; // Clip to content area var contentRect = new SKRect( - bounds.Left + Padding, - bounds.Top + Padding, - bounds.Right - Padding, - bounds.Bottom - Padding); + bounds.Left + paddingLeft, + bounds.Top + paddingTop, + bounds.Right - paddingRight, + bounds.Bottom - paddingBottom); canvas.Save(); canvas.ClipRect(contentRect); @@ -548,25 +780,26 @@ public class SkiaEditor : SkiaView { using var placeholderPaint = new SKPaint(font) { - Color = PlaceholderColor, + Color = ToSKColor(PlaceholderColor), IsAntialias = true }; - canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + FontSize, placeholderPaint); + canvas.DrawText(Placeholder, contentRect.Left, contentRect.Top + fontSize, placeholderPaint); } else { + var textColor = ToSKColor(TextColor); using var textPaint = new SKPaint(font) { - Color = IsEnabled ? TextColor : TextColor.WithAlpha(128), + Color = IsEnabled ? textColor : textColor.WithAlpha(128), IsAntialias = true }; using var selectionPaint = new SKPaint { - Color = SelectionColor, + Color = ToSKColor(SelectionColor), Style = SKPaintStyle.Fill }; - var y = contentRect.Top + FontSize; + var y = contentRect.Top + fontSize; var charIndex = 0; for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++) @@ -591,7 +824,7 @@ public class SkiaEditor : SkiaView 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); + canvas.DrawRect(new SKRect(startX, y - fontSize, endX, y + lineSpacing - fontSize), selectionPaint); } } @@ -606,12 +839,12 @@ public class SkiaEditor : SkiaView var cursorX = x + MeasureText(line.Substring(0, Math.Min(cursorCol, line.Length)), font); using var cursorPaint = new SKPaint { - Color = CursorColor, + Color = ToSKColor(CursorColor), Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true }; - canvas.DrawLine(cursorX, y - FontSize + 2, cursorX, y + 2, cursorPaint); + canvas.DrawLine(cursorX, y - fontSize + 2, cursorX, y + 2, cursorPaint); } } @@ -623,7 +856,7 @@ public class SkiaEditor : SkiaView canvas.Restore(); // Draw scrollbar if needed - var totalHeight = _lines.Count * FontSize * LineHeight; + var totalHeight = _lines.Count * fontSize * lineHeight; if (totalHeight > contentRect.Height) { DrawScrollbar(canvas, bounds, contentRect.Height, totalHeight); @@ -641,8 +874,9 @@ public class SkiaEditor : SkiaView { var scrollbarWidth = 6f; var scrollbarMargin = 2f; + var paddingTop = (float)Padding.Top; var scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight)); - var scrollbarY = bounds.Top + Padding + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight); + var scrollbarY = bounds.Top + paddingTop + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight); using var paint = new SKPaint { @@ -663,9 +897,11 @@ public class SkiaEditor : SkiaView private void EnsureCursorVisible() { var (line, col) = GetLineColumn(_cursorPosition); - var lineSpacing = FontSize * LineHeight; + var fontSize = (float)FontSize; + var lineHeight = (float)LineHeight; + var lineSpacing = fontSize * lineHeight; var cursorY = line * lineSpacing; - var viewHeight = Bounds.Height - Padding * 2; + var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom); if (cursorY < _scrollOffsetY) { @@ -685,13 +921,16 @@ public class SkiaEditor : SkiaView // Use screen coordinates for proper hit detection var screenBounds = ScreenBounds; - var contentX = e.X - screenBounds.Left - Padding; - var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY; + var 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 lineSpacing = FontSize * LineHeight; + 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); + using var font = new SKFont(SKTypeface.Default, fontSize); var line = _lines[clickedLine]; var clickedCol = 0; @@ -743,13 +982,16 @@ public class SkiaEditor : SkiaView // Calculate position from mouse coordinates var screenBounds = ScreenBounds; - var contentX = e.X - screenBounds.Left - Padding; - var contentY = e.Y - screenBounds.Top - Padding + _scrollOffsetY; + 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 lineSpacing = FontSize * LineHeight; + 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); + using var font = new SKFont(SKTypeface.Default, fontSize); var line = _lines[clickedLine]; var clickedCol = 0; @@ -963,9 +1205,11 @@ public class SkiaEditor : SkiaView public override void OnScroll(ScrollEventArgs e) { - var lineSpacing = FontSize * LineHeight; + var fontSize = (float)FontSize; + var lineHeight = (float)LineHeight; + var lineSpacing = fontSize * lineHeight; var totalHeight = _lines.Count * lineSpacing; - var viewHeight = Bounds.Height - Padding * 2; + var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom); var maxScroll = Math.Max(0, totalHeight - viewHeight); _scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll); @@ -1073,8 +1317,11 @@ public class SkiaEditor : SkiaView { if (AutoSize) { - var lineSpacing = FontSize * LineHeight; - var height = Math.Max(lineSpacing + Padding * 2, _lines.Count * lineSpacing + Padding * 2); + var fontSize = (float)FontSize; + var lineHeight = (float)LineHeight; + var lineSpacing = fontSize * lineHeight; + var verticalPadding = (float)(Padding.Top + Padding.Bottom); + var height = Math.Max(lineSpacing + verticalPadding, _lines.Count * lineSpacing + verticalPadding); return new SKSize( availableSize.Width < float.MaxValue ? availableSize.Width : 200, (float)Math.Min(height, availableSize.Height < float.MaxValue ? availableSize.Height : 200)); diff --git a/Views/SkiaLabel.cs b/Views/SkiaLabel.cs index 32609ad..26c7de5 100644 --- a/Views/SkiaLabel.cs +++ b/Views/SkiaLabel.cs @@ -347,6 +347,38 @@ public class SkiaLabel : SkiaView #endregion + #region Selection State + + private int _selectionStart = -1; + private int _selectionLength = 0; + private bool _isSelecting = false; + private DateTime _lastClickTime = DateTime.MinValue; + private float _lastClickX; + private const double DoubleClickThresholdMs = 400; + + /// + /// Gets or sets whether text selection is enabled. + /// + public bool IsTextSelectionEnabled { get; set; } = true; + + /// + /// Gets the currently selected text. + /// + public string SelectedText + { + get + { + if (_selectionStart < 0 || _selectionLength == 0) return string.Empty; + var text = GetDisplayText(); + var start = Math.Min(_selectionStart, _selectionStart + _selectionLength); + var length = Math.Abs(_selectionLength); + if (start < 0 || start >= text.Length) return string.Empty; + return text.Substring(start, Math.Min(length, text.Length - start)); + } + } + + #endregion + #region Events /// @@ -362,10 +394,177 @@ public class SkiaLabel : SkiaView Tapped?.Invoke(this, EventArgs.Empty); } + public override void OnPointerPressed(PointerEventArgs e) + { + base.OnPointerPressed(e); + + if (!IsTextSelectionEnabled || string.IsNullOrEmpty(Text)) return; + + var text = GetDisplayText(); + if (string.IsNullOrEmpty(text)) return; + + // Calculate character position from click + var screenBounds = ScreenBounds; + var clickX = e.X - screenBounds.Left - (float)Padding.Left; + var charIndex = GetCharacterIndexAtX(clickX); + + // Check for double-click (select word) + var now = DateTime.UtcNow; + var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds; + var distanceFromLastClick = Math.Abs(e.X - _lastClickX); + + if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10) + { + // Double-click: select word + SelectWordAt(charIndex); + _lastClickTime = DateTime.MinValue; + _isSelecting = false; + } + else + { + // Single click: start selection + _selectionStart = charIndex; + _selectionLength = 0; + _isSelecting = true; + _lastClickTime = now; + _lastClickX = e.X; + } + + Invalidate(); + } + + public override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (!IsTextSelectionEnabled || !_isSelecting) return; + + var text = GetDisplayText(); + if (string.IsNullOrEmpty(text)) return; + + var screenBounds = ScreenBounds; + var clickX = e.X - screenBounds.Left - (float)Padding.Left; + var charIndex = GetCharacterIndexAtX(clickX); + + _selectionLength = charIndex - _selectionStart; + Invalidate(); + } + public override void OnPointerReleased(PointerEventArgs e) { base.OnPointerReleased(e); - OnTapped(); + + if (_isSelecting && _selectionLength == 0) + { + // No drag happened, it's a tap + OnTapped(); + } + + _isSelecting = false; + } + + public override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + + if (!IsTextSelectionEnabled) return; + + // Ctrl+A: Select All + if (e.Key == Key.A && e.Modifiers.HasFlag(KeyModifiers.Control)) + { + SelectAll(); + e.Handled = true; + } + // Ctrl+C: Copy + else if (e.Key == Key.C && e.Modifiers.HasFlag(KeyModifiers.Control)) + { + CopyToClipboard(); + e.Handled = true; + } + } + + /// + /// Selects all text in the label. + /// + public void SelectAll() + { + var text = GetDisplayText(); + _selectionStart = 0; + _selectionLength = text.Length; + Invalidate(); + } + + /// + /// Clears the current selection. + /// + public void ClearSelection() + { + _selectionStart = -1; + _selectionLength = 0; + Invalidate(); + } + + private void SelectWordAt(int charIndex) + { + var text = GetDisplayText(); + if (string.IsNullOrEmpty(text) || charIndex < 0 || charIndex >= text.Length) return; + + int start = charIndex; + int end = charIndex; + + // 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; + _selectionLength = end - start; + } + + private static bool IsWordChar(char c) + { + return char.IsLetterOrDigit(c) || c == '_'; + } + + private int GetCharacterIndexAtX(float x) + { + var text = GetDisplayText(); + if (string.IsNullOrEmpty(text)) return 0; + + float fontSize = FontSize > 0 ? (float)FontSize : 14f; + var fontFamily = string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily; + + using var font = new SKFont( + SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(fontFamily, GetFontStyle()) ?? SKTypeface.Default, + fontSize); + using var paint = new SKPaint(font); + + for (int i = 0; i <= text.Length; i++) + { + var substring = text.Substring(0, i); + var width = paint.MeasureText(substring); + if (CharacterSpacing != 0 && i > 0) + { + width += (float)(CharacterSpacing * i); + } + if (width > x) + { + return i > 0 ? i - 1 : 0; + } + } + return text.Length; + } + + private void CopyToClipboard() + { + var selectedText = SelectedText; + if (!string.IsNullOrEmpty(selectedText)) + { + SystemClipboard.SetText(selectedText); + } } #endregion @@ -533,10 +732,50 @@ public class SkiaLabel : SkiaView _ => bounds.MidY - textBounds.MidY }; + // Draw selection highlight if applicable + if (_selectionStart >= 0 && _selectionLength != 0) + { + DrawSelectionHighlight(canvas, paint, x, y, displayText, textBounds); + } + DrawTextWithSpacing(canvas, displayText, x, y, paint); DrawTextDecorations(canvas, paint, x, y, textBounds); } + private void DrawSelectionHighlight(SKCanvas canvas, SKPaint paint, float x, float y, string text, SKRect textBounds) + { + var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength); + var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength); + + // Clamp to text length + selStart = Math.Max(0, Math.Min(selStart, text.Length)); + selEnd = Math.Max(0, Math.Min(selEnd, text.Length)); + + if (selStart >= selEnd) return; + + var textToStart = text.Substring(0, selStart); + var textToEnd = text.Substring(0, selEnd); + + float startX = x + paint.MeasureText(textToStart); + float endX = x + paint.MeasureText(textToEnd); + + if (CharacterSpacing != 0) + { + startX += (float)(CharacterSpacing * selStart); + endX += (float)(CharacterSpacing * selEnd); + } + + using var selectionPaint = new SKPaint + { + Color = new SKColor(0x21, 0x96, 0xF3, 0x60), // Semi-transparent blue + Style = SKPaintStyle.Fill + }; + + float selectionTop = y + textBounds.Top; + float selectionBottom = y + textBounds.Bottom; + canvas.DrawRect(new SKRect(startX, selectionTop, endX, selectionBottom), selectionPaint); + } + private void DrawMultiLineText(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds, string text) { float lineHeight = (float)(FontSize * LineHeight); diff --git a/Views/SkiaSearchBar.cs b/Views/SkiaSearchBar.cs index 64824c9..3678580 100644 --- a/Views/SkiaSearchBar.cs +++ b/Views/SkiaSearchBar.cs @@ -1,8 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using SkiaSharp; +using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform.Linux.Rendering; +using SkiaSharp; namespace Microsoft.Maui.Platform; @@ -26,13 +27,13 @@ public class SkiaSearchBar : SkiaView set => _entry.Placeholder = value; } - public SKColor TextColor + public Color TextColor { get => _entry.TextColor; set => _entry.TextColor = value; } - public SKColor PlaceholderColor + public Color PlaceholderColor { get => _entry.PlaceholderColor; set => _entry.PlaceholderColor = value; @@ -55,10 +56,10 @@ public class SkiaSearchBar : SkiaView _entry = new SkiaEntry { Placeholder = "Search...", - EntryBackgroundColor = SKColors.Transparent, + EntryBackgroundColor = Colors.Transparent, BackgroundColor = SKColors.Transparent, - BorderColor = SKColors.Transparent, - FocusedBorderColor = SKColors.Transparent, + BorderColor = Colors.Transparent, + FocusedBorderColor = Colors.Transparent, BorderWidth = 0 };