Files

655 lines
19 KiB
C#
Raw Permalink Normal View History

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