// 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 /// /// Gets or sets the text for IME context. /// string IInputContext.Text { get => Text; set => Text = value; } /// /// Gets or sets the cursor position for IME context. /// int IInputContext.CursorPosition { get => _cursorPosition; set => CursorPosition = value; } /// /// Gets the selection start for IME context. /// int IInputContext.SelectionStart => _selectionStart; /// /// Gets the selection length for IME context. /// int IInputContext.SelectionLength => _selectionLength; /// /// Called when IME commits text. /// 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(); } /// /// Called when IME pre-edit (composition) text changes. /// public void OnPreEditChanged(string preEditText, int cursorPosition) { _preEditText = preEditText ?? string.Empty; _preEditCursorPosition = cursorPosition; Invalidate(); } /// /// Called when IME pre-edit ends (cancelled or committed). /// 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; } /// /// Selects all text. /// 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 { 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 { 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; } /// /// Updates the IME cursor location for candidate window positioning. /// 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); } }