From b20b93b5f96126f7c3451f01c1b0404e74e84ec9 Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Thu, 4 Jun 2026 19:22:02 +0500 Subject: [PATCH 1/8] added clipboard paste support --- src/Columns/TableViewColumn.cs | 37 ++ src/Extensions/ObjectExtensions.cs | 467 +++++++++++++++++++++++ src/Strings/en-US/WinUI.TableView.resw | 6 + src/TableView.Paste.cs | 225 +++++++++++ src/TableView.cs | 4 + src/TableViewHeaderRow.OptionComamnds.cs | 14 + src/TableViewLocalizedStrings.cs | 4 + src/Themes/TableViewHeaderRow.xaml | 8 + 8 files changed, 765 insertions(+) create mode 100644 src/TableView.Paste.cs diff --git a/src/Columns/TableViewColumn.cs b/src/Columns/TableViewColumn.cs index 494aa2b6..2e7a7dda 100644 --- a/src/Columns/TableViewColumn.cs +++ b/src/Columns/TableViewColumn.cs @@ -2,6 +2,7 @@ using Microsoft.UI.Xaml.Data; using System; using System.Collections.Generic; +using System.Diagnostics; using WinUI.TableView.Extensions; using SD = WinUI.TableView.SortDirection; @@ -16,6 +17,7 @@ public abstract partial class TableViewColumn : DependencyObject { private Func? _funcCompiledPropertyPath; private Func? _funcCompiledClipboardPropertyPath; + private Action? _compliedValueSetter; /// /// Initializes a new instance of the class with default conditional cell styles. @@ -152,6 +154,41 @@ internal void SetOwningTableView(TableView tableView) return dataItem; } + /// + /// Sets the content from the clipboard to the specified data item. + /// + /// The data item. + /// The value to set. + /// if the value was set; otherwise, . + public virtual bool SetClipboardContent(object? dataItem, object? value) + { + if (dataItem is null) + return false; + + try + { + if (_compliedValueSetter is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) + _compliedValueSetter = dataItem.GetCompiledValueSetter(ClipboardContentBindingPropertyPath!); + + if (ClipboardContentBinding?.Converter is not null) + { + value = ClipboardContentBinding.Converter.ConvertBack( + value, + typeof(object), + ClipboardContentBinding.ConverterParameter, + ClipboardContentBinding.ConverterLanguage); + } + + _compliedValueSetter?.Invoke(dataItem, value); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"TableViewColumn: SetClipboardContent failed: {ex}"); + return false; + } + } + /// /// Gets or sets the header content of the column. /// diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 1cd519b8..00041550 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -1,6 +1,8 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -41,6 +43,471 @@ internal static partial class ObjectExtensions } } + internal static Action? GetCompiledValueSetter(this object dataItem, string bindingPath) + { + try + { + var parameterObj = Expression.Parameter(typeof(object), "obj"); + var parameterValue = Expression.Parameter(typeof(object), "value"); + + var expressionTree = BuildPropertyPathSetterExpressionTree( + parameterObj, + parameterValue, + bindingPath, + dataItem); + + var lambda = Expression.Lambda>( + expressionTree, + parameterObj, + parameterValue); + + return lambda.Compile(); + } + catch + { + return null; + } + } + + private static Expression BuildPropertyPathSetterExpressionTree(ParameterExpression parameterObj, ParameterExpression parameterValue, string bindingPath, object dataItem) + { + var matches = PropertyPathRegex().Matches(bindingPath); + + if (matches.Count == 0) + throw new ArgumentException("Binding path is empty.", nameof(bindingPath)); + + Expression current = parameterObj; + + var actualType = dataItem.GetType(); + + if (current.Type != actualType && !actualType.IsValueType) + current = Expression.Convert(current, actualType); + + // Navigate to the parent object of the final path segment. + for (int i = 0; i < matches.Count - 1; i++) + { + var part = matches[i].Value; + + current = part.StartsWith('[') && part.EndsWith(']') + ? BuildIndexerGetterExpression(current, part) + : BuildPropertyGetterExpression(current, part); + + var lambdaTemp = Expression.Lambda>( + EnsureObjectCompatibleResult(current), + parameterObj); + + var currentValue = lambdaTemp.Compile()(dataItem); + + if (currentValue is null) + throw new ArgumentException($"Cannot build setter. Path segment '{part}' evaluates to null."); + + var runtimeType = currentValue.GetType(); + + if (current.Type != runtimeType) + current = Expression.Convert(current, runtimeType); + } + + var finalPart = matches[^1].Value; + + return finalPart.StartsWith('[') && finalPart.EndsWith(']') + ? BuildIndexerSetterExpression(current, finalPart, parameterValue) + : BuildPropertySetterExpression(current, finalPart, parameterValue); + } + + private static Expression BuildPropertyGetterExpression(Expression current, string propertyName) + { + var propertyInfo = current.Type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new ArgumentException($"Property '{propertyName}' not found on type '{current.Type.Name}'."); + + return Expression.Property(current, propertyInfo); + } + + private static Expression BuildIndexerGetterExpression(Expression current, string indexerPart) + { + var indices = GetIndices(indexerPart[1..^1]); + + if (current.Type.IsArray) + { + if (!indices.All(index => index is int)) + throw new ArgumentException($"Arrays only support integer indexing: {indexerPart}"); + + return AddArrayAccessWithBoundsCheck(current, [.. indices.Select(index => (int)index)]); + } + + return AddIndexerAccessWithSafetyChecks(current, indices); + } + + private static Expression BuildPropertySetterExpression(Expression current, string propertyName, Expression value) + { + var propertyInfo = current.Type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new ArgumentException($"Property '{propertyName}' not found on type '{current.Type.Name}'."); + + if (!propertyInfo.CanWrite) + throw new ArgumentException($"Property '{propertyName}' is read-only."); + + var target = Expression.Property(current, propertyInfo); + + return BuildConvertedAssignExpression(target, value, propertyInfo.PropertyType); + } + + private static Expression BuildIndexerSetterExpression(Expression current, string indexerPart, Expression value) + { + var indices = GetIndices(indexerPart[1..^1]); + + if (current.Type.IsArray) + return BuildArraySetterExpression(current, indices, value); + + return BuildObjectIndexerSetterExpression(current, indices, value); + } + + private static Expression BuildArraySetterExpression(Expression current, object[] indices, Expression value) + { + if (!indices.All(index => index is int)) + throw new ArgumentException("Arrays only support integer indexers."); + + var arrayType = current.Type; + var elementType = arrayType.GetElementType()!; + var rank = arrayType.GetArrayRank(); + + if (indices.Length != rank) + throw new ArgumentException($"Array rank mismatch. Expected {rank} index(es)."); + + var arrayVar = Expression.Parameter(arrayType, "array"); + var assignArray = Expression.Assign(arrayVar, current); + + var indexExpressions = indices + .Cast() + .Select(index => Expression.Constant(index)) + .ToArray(); + + Expression? boundsCheck = null; + + var getLengthMethod = typeof(Array).GetMethod(nameof(Array.GetLength))!; + + for (var i = 0; i < indexExpressions.Length; i++) + { + var index = (int)indices[i]; + + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(indices)); + + var length = Expression.Call(arrayVar, getLengthMethod, Expression.Constant(i)); + var check = Expression.LessThan(indexExpressions[i], length); + + boundsCheck = boundsCheck is null + ? check + : Expression.AndAlso(boundsCheck, check); + } + + var assignValue = BuildConvertedAssignExpression( + Expression.ArrayAccess(arrayVar, indexExpressions), + value, + elementType); + + return Expression.Block( + [arrayVar], + assignArray, + Expression.IfThen(boundsCheck!, assignValue)); + } + + private static Expression BuildObjectIndexerSetterExpression(Expression current, object[] indices, Expression value) + { + if (indices.Length == 1) + { + var dictionaryInterface = current.Type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + + if (dictionaryInterface is not null) + { + var args = dictionaryInterface.GetGenericArguments(); + var keyType = args[0]; + var valueType = args[1]; + + if (!keyType.IsAssignableFrom(indices[0].GetType())) + return Expression.Empty(); + + var indexer = dictionaryInterface.GetProperty("Item")!; + + var target = Expression.Property(Expression.Convert(current, dictionaryInterface), + indexer, + Expression.Constant(indices[0], keyType)); + + return BuildConvertedAssignExpression(target, value, valueType); + } + } + + var indexerTypes = indices.Select(index => index.GetType()).ToArray(); + + var indexerProperty = current.Type.GetProperty("Item", indexerTypes) + ?? throw new ArgumentException($"Indexer not found on type '{current.Type.Name}'."); + + if (!indexerProperty.CanWrite) + throw new ArgumentException($"Indexer on type '{current.Type.Name}' is read-only."); + + var indexExpressions = indices + .Select(index => Expression.Constant(index, index.GetType())) + .ToArray(); + + // Add bounds checking for IList/ICollection types with integer indexers + if (indices.Length == 1 && indices[0] is int intIndex) + { + var listInterface = current.Type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && + (i.GetGenericTypeDefinition() == typeof(IList<>) || + i.GetGenericTypeDefinition() == typeof(ICollection<>))); + + if (listInterface != null || + typeof(IList).IsAssignableFrom(current.Type) || + typeof(ICollection).IsAssignableFrom(current.Type)) + { + var countProperty = current.Type.GetProperty("Count"); + + if (countProperty != null) + { + var collectionVar = Expression.Parameter(current.Type, "collection"); + var assignCollection = Expression.Assign(collectionVar, current); + + var countExpr = Expression.Property(collectionVar, countProperty); + var indexExpr = Expression.Constant(intIndex); + + // Check if index >= 0 && index < Count + var boundsCheck = Expression.AndAlso( + Expression.GreaterThanOrEqual(indexExpr, Expression.Constant(0)), + Expression.LessThan(indexExpr, countExpr)); + + var assignValue = BuildConvertedAssignExpression( + Expression.Property(collectionVar, indexerProperty, indexExpressions), + value, + indexerProperty.PropertyType); + + return Expression.Block( + [collectionVar], + assignCollection, + Expression.IfThen(boundsCheck, assignValue)); + } + } + } + + return BuildConvertedAssignExpression( + Expression.Property(current, indexerProperty, indexExpressions), + value, + indexerProperty.PropertyType); + } + + private static Expression BuildConvertedAssignExpression(Expression target, Expression value, Type targetType) + { + var convertedValue = Expression.Variable(typeof(object), "convertedValue"); + var error = Expression.Variable(typeof(string), "error"); + + var tryConvertMethod = typeof(ObjectExtensions) + .GetMethod( + nameof(TryConvertValue), + BindingFlags.Static | BindingFlags.NonPublic)!; + + var tryConvertCall = Expression.Call( + tryConvertMethod, + value, + Expression.Constant(targetType), + convertedValue, + error); + + return Expression.Block( + [convertedValue, error], + Expression.IfThen( + tryConvertCall, + Expression.Assign( + target, + Expression.Convert(convertedValue, targetType)))); + } + + private static bool TryConvertValue(object? value, Type targetType, out object? convertedValue, out string? error) + { + if (targetType == typeof(object)) + { + error = null; + convertedValue = value; + return true; + } + + if (value is string || value is null) + return TryConvertToTargetType(value as string, targetType, out convertedValue, out error); + + return TryConvertObject(value, targetType, out convertedValue, out error); + } + + private static bool TryConvertToTargetType(string? stringValue, Type targetType, out object? convertedValue, out string? error) + { + error = null; + convertedValue = null; + + var underlyingType = Nullable.GetUnderlyingType(targetType); + var actualTargetType = underlyingType ?? targetType; + + if (actualTargetType == typeof(string)) + { + convertedValue = stringValue ?? string.Empty; + return true; + } + + if (stringValue is null) + { + if (underlyingType is not null || !targetType.IsValueType) + { + return true; + } + + error = $"The target type '{targetType.Name}' does not accept null values."; + return false; + } + + if (stringValue.Length == 0) + { + if (underlyingType is not null || !targetType.IsValueType) + { + return true; + } + + error = $"The target type '{targetType.Name}' does not accept empty values."; + return false; + } + + try + { + if (actualTargetType == typeof(bool)) + { + if (bool.TryParse(stringValue, out var boolValue)) + { + convertedValue = boolValue; + return true; + } + + if (stringValue == "1" || stringValue.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + convertedValue = true; + return true; + } + + if (stringValue == "0" || stringValue.Equals("no", StringComparison.OrdinalIgnoreCase)) + { + convertedValue = false; + return true; + } + } + else if (actualTargetType == typeof(DateOnly)) + { + convertedValue = DateOnly.Parse(stringValue, CultureInfo.CurrentCulture); + return true; + } + else if (actualTargetType == typeof(TimeOnly)) + { + convertedValue = TimeOnly.Parse(stringValue, CultureInfo.CurrentCulture); + return true; + } + else if (actualTargetType == typeof(DateTime)) + { + convertedValue = DateTime.Parse(stringValue, CultureInfo.CurrentCulture); + return true; + } + else if (actualTargetType == typeof(DateTimeOffset)) + { + convertedValue = DateTimeOffset.Parse(stringValue, CultureInfo.CurrentCulture); + return true; + } + else if (actualTargetType == typeof(TimeSpan)) + { + convertedValue = TimeSpan.Parse(stringValue, CultureInfo.CurrentCulture); + return true; + } + else if (actualTargetType == typeof(Guid)) + { + convertedValue = Guid.Parse(stringValue); + return true; + } + else if (actualTargetType == typeof(Uri)) + { + convertedValue = new Uri(stringValue, UriKind.RelativeOrAbsolute); + return true; + } + else if (actualTargetType.IsEnum) + { + convertedValue = Enum.Parse(actualTargetType, stringValue, ignoreCase: true); + return true; + } + + var converter = TypeDescriptor.GetConverter(actualTargetType); + if (converter.CanConvertFrom(typeof(string))) + { + convertedValue = converter.ConvertFrom(null, CultureInfo.CurrentCulture, stringValue); + return true; + } + + convertedValue = Convert.ChangeType(stringValue, actualTargetType, CultureInfo.CurrentCulture); + return true; + } + catch (Exception ex) + { + error = $"Unable to convert '{stringValue}' to '{actualTargetType.Name}': {ex.Message}"; + return false; + } + } + + private static bool TryConvertObject(object? value, Type targetType, out object? convertedValue, out string? error) + { + error = null; + convertedValue = null; + + if (value is null) + { + if (!targetType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null) + { + return true; + } + + error = $"The target type '{targetType.Name}' does not accept null values."; + return false; + } + + var actualTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (actualTargetType.IsInstanceOfType(value)) + { + convertedValue = value; + return true; + } + + try + { + convertedValue = Convert.ChangeType(value, actualTargetType, CultureInfo.CurrentCulture); + return true; + } + catch (Exception ex) + { + error = $"Unable to convert '{value}' to '{actualTargetType.Name}': {ex.Message}"; + return false; + } + } + + private static Expression ConvertValueExpression(Expression value, Type targetType) + { + if (targetType == typeof(object)) + return value; + + var nullableType = Nullable.GetUnderlyingType(targetType); + + if (nullableType is not null) + { + return Expression.Condition( + Expression.Equal(value, Expression.Constant(null)), + Expression.Constant(null, targetType), + Expression.Convert(value, targetType)); + } + + if (!targetType.IsValueType) + return Expression.Convert(value, targetType); + + return Expression.Convert(value, targetType); + } + /// /// Builds an expression tree for accessing a property path on the given instance expression, with runtime type checking and casting support. /// diff --git a/src/Strings/en-US/WinUI.TableView.resw b/src/Strings/en-US/WinUI.TableView.resw index 9c722cf9..11fddb2c 100644 --- a/src/Strings/en-US/WinUI.TableView.resw +++ b/src/Strings/en-US/WinUI.TableView.resw @@ -180,4 +180,10 @@ Copy + + Paste + + + Paste clipboard content into the selected cells. + \ No newline at end of file diff --git a/src/TableView.Paste.cs b/src/TableView.Paste.cs new file mode 100644 index 00000000..a32972c4 --- /dev/null +++ b/src/TableView.Paste.cs @@ -0,0 +1,225 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Windows.ApplicationModel.DataTransfer; +using WinUI.TableView.Helpers; + +namespace WinUI.TableView; + +/// +/// Provides clipboard paste functionality for the TableView control, allowing users to paste tabular data from the clipboard into the table. +/// +public partial class TableView +{ + /// + /// Attempts to initiate a paste operation from the clipboard. + /// + /// True if the paste operation was initiated successfully; otherwise, false. + internal bool TryStartPasteFromClipboard() + { + if (!CanHandlePasteRequest()) + { + return false; + } + + PasteFromClipboard(); + return true; + } + + /// + /// Handles the actual paste operation from the clipboard. + /// + private async void PasteFromClipboard() + { + try + { + var content = Clipboard.GetContent(); + if (!content.Contains(StandardDataFormats.Text)) + { + Debug.WriteLine("TableView: Clipboard does not contain text data to paste."); + return; + } + + _collectionView.AllowLiveShaping = false; + + var text = await content.GetTextAsync(); + var pastedAnyValue = PasteClipboardTextInternal(text); + + if (pastedAnyValue) + { + // Refresh the view to ensure any changes are reflected in the UI + if (SortDescriptions.Count > 0 || FilterDescriptions.Count > 0) + RefreshView(); + } + + _collectionView.AllowLiveShaping = true; + } + catch (Exception ex) + { + Debug.WriteLine($"TableView: Clipboard.GetText failed: {ex}"); + } + } + + /// + /// Processes the clipboard text and pastes it into the table starting from the determined anchor cell. + /// + /// The text content from the clipboard to be pasted into the table. + /// True if any value was successfully pasted; otherwise, false. + private bool PasteClipboardTextInternal(string? text) + { + var rows = ParseClipboardText(text); + if (rows.Count == 0) + { + return false; + } + + if (!TryGetPasteAnchor(out var anchor)) + { + Debug.WriteLine("TableView: Paste requires a current cell or a selected row/cell."); + return false; + } + + var pastedAnyValue = false; + for (var rowOffset = 0; rowOffset < rows.Count; rowOffset++) + { + var rowIndex = anchor.Row + rowOffset; + if (rowIndex < 0 || rowIndex >= Items.Count) + { + break; + } + + if (Items[rowIndex] is not object item) + { + continue; + } + + var values = rows[rowOffset]; + var columnIndex = anchor.Column; + for (var valueIndex = 0; valueIndex < values.Length; valueIndex++) + { + if (columnIndex >= Columns.VisibleColumns.Count) break; + + var column = Columns.VisibleColumns[columnIndex]; + var value = values[valueIndex]; + + if (column.IsReadOnly) continue; + + pastedAnyValue = column.SetClipboardContent(item, value); + columnIndex++; + } + } + + return pastedAnyValue; + } + + /// + /// Parses the clipboard text into a list of rows, where each row is an array of values. + /// + /// The text content from the clipboard to be parsed. + /// A list of rows, where each row is an array of values. + private static IReadOnlyList ParseClipboardText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + var normalizedText = text.ReplaceLineEndings("\n"); + var rows = normalizedText.Split('\n'); + + if (rows.Length > 0 && rows[^1].Length == 0) + { + rows = rows[..^1]; + } + + return [.. rows.Select(row => row.Split('\t'))]; + } + + /// + /// Determines whether the TableView can handle a paste request based on its current state. + /// + /// True if the TableView can handle the paste request; otherwise, false. + private bool CanHandlePasteRequest() + { + if (IsReadOnly || IsEditing || Columns.VisibleColumns.Count == 0 || Items.Count == 0) + { + return false; + } + + var focused = FocusManager.GetFocusedElement(XamlRoot!) as FrameworkElement; + return focused is not TextBox and not PasswordBox and not RichEditBox; + } + + /// + /// Attempts to determine the anchor cell for pasting based on the current state of the TableView. + /// + /// The determined anchor cell for pasting. + /// True if a valid paste anchor is found; otherwise, false. + private bool TryGetPasteAnchor(out TableViewCellSlot anchor) + { + if (CurrentCellSlot is { } currentCell && IsValidPasteAnchor(currentCell)) + { + anchor = currentCell; + return true; + } + + if (SelectedCells.Count > 0) + { + anchor = SelectedCells.OrderBy(slot => slot.Row) + .ThenBy(slot => slot.Column) + .First(); + return true; + } + + var rowIndex = SelectedRanges.Any() + ? SelectedRanges.Min(range => range.FirstIndex) + : CurrentRowIndex; + + if (rowIndex is >= 0) + { + var columnIndex = GetFirstWritableVisibleColumnIndex(); + if (columnIndex >= 0) + { + anchor = new TableViewCellSlot(rowIndex.Value, columnIndex); + return true; + } + } + + anchor = default; + return false; + } + + /// + /// Validates whether the given TableViewCellSlot is a valid anchor for pasting. + /// + /// The TableViewCellSlot to validate. + /// True if the slot is a valid paste anchor; otherwise, false. + private bool IsValidPasteAnchor(TableViewCellSlot slot) + { + return slot.Row >= 0 + && slot.Row < Items.Count + && slot.Column >= 0 + && slot.Column < Columns.VisibleColumns.Count; + } + + /// + /// Finds the index of the first visible column that is writable (not read-. + /// + /// The index of the first writable visible column, or -1 if none are found. + private int GetFirstWritableVisibleColumnIndex() + { + for (var i = 0; i < Columns.VisibleColumns.Count; i++) + { + if (Columns.VisibleColumns[i] is { IsReadOnly: false, ClipboardContentBinding: { } }) + { + return i; + } + } + + return -1; + } +} diff --git a/src/TableView.cs b/src/TableView.cs index 77156a97..9835a65e 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -326,6 +326,10 @@ private bool HandleShortKeys(bool shiftKey, bool ctrlKey, VirtualKey key) CopyToClipboardInternal(shiftKey); return true; } + else if (key == VirtualKey.V && ctrlKey && !shiftKey) + { + return TryStartPasteFromClipboard(); + } return false; } diff --git a/src/TableViewHeaderRow.OptionComamnds.cs b/src/TableViewHeaderRow.OptionComamnds.cs index d8b5f8b7..71fdafaa 100644 --- a/src/TableViewHeaderRow.OptionComamnds.cs +++ b/src/TableViewHeaderRow.OptionComamnds.cs @@ -12,6 +12,7 @@ partial class TableViewHeaderRow private readonly StandardUICommand _selectAllCommand = new(StandardUICommandKind.SelectAll) { Label = TableViewLocalizedStrings.SelectAll }; private readonly StandardUICommand _deselectAllCommand = new() { Label = TableViewLocalizedStrings.DeselectAll }; private readonly StandardUICommand _copyCommand = new(StandardUICommandKind.Copy) { Label = TableViewLocalizedStrings.Copy }; + private readonly StandardUICommand _pasteCommand = new(StandardUICommandKind.Paste) { Label = TableViewLocalizedStrings.Paste }; private readonly StandardUICommand _copyWithHeadersCommand = new() { Label = TableViewLocalizedStrings.CopyWithHeaders }; private readonly StandardUICommand _clearSortingCommand = new() { Label = TableViewLocalizedStrings.ClearSorting }; private readonly StandardUICommand _clearFilterCommand = new() { Label = TableViewLocalizedStrings.ClearFilter }; @@ -42,6 +43,8 @@ private void SetOptionCommands() clearSelectionMenuItem.Command = _deselectAllCommand; if (GetTemplateChild("CopyMenuItem") is MenuFlyoutItem copyMenuItem) copyMenuItem.Command = _copyCommand; + if (GetTemplateChild("PasteMenuItem") is MenuFlyoutItem pasteMenuItem) + pasteMenuItem.Command = _pasteCommand; if (GetTemplateChild("CopyWithHeadersMenuItem") is MenuFlyoutItem copyWithHeadersMenuItem) copyWithHeadersMenuItem.Command = _copyWithHeadersCommand; if (GetTemplateChild("ClearSortingMenuItem") is MenuFlyoutItem clearSortingMenuItem) @@ -82,6 +85,10 @@ private void InitializeCommands() _copyCommand.ExecuteRequested += ExecuteCopyCommand; _copyCommand.CanExecuteRequested += CanExecuteCopyCommand; + _pasteCommand.Description = TableViewLocalizedStrings.PasteCommandDescription; + _pasteCommand.ExecuteRequested += delegate { TableView?.TryStartPasteFromClipboard(); }; + _pasteCommand.CanExecuteRequested += CanExecutePasteCommand; + _copyWithHeadersCommand.Description = TableViewLocalizedStrings.CopyWithHeadersCommandDescription; _copyWithHeadersCommand.ExecuteRequested += delegate { TableView?.CopyToClipboardInternal(true); }; _copyWithHeadersCommand.CanExecuteRequested += CanExecuteCopyWithHeadersCommand; @@ -130,6 +137,13 @@ private void CanExecuteCopyWithHeadersCommand(XamlUICommand sender, CanExecuteRe e.CanExecute = TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true; } + private void CanExecutePasteCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) + { + e.CanExecute = TableView?.IsEditing is false + && TableView?.IsReadOnly is false + && (TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true); + } + private void CanExecuteClearSortingCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) { e.CanExecute = TableView?.IsEditing is false && TableView.IsSorted; diff --git a/src/TableViewLocalizedStrings.cs b/src/TableViewLocalizedStrings.cs index 3403060c..339600fc 100644 --- a/src/TableViewLocalizedStrings.cs +++ b/src/TableViewLocalizedStrings.cs @@ -24,6 +24,8 @@ static TableViewLocalizedStrings() ClearSorting = GetValue(nameof(ClearSorting)); Copy = GetValue(nameof(Copy)); CopyCommandDescription = GetValue(nameof(CopyCommandDescription)); + Paste = GetValue(nameof(Paste)); + PasteCommandDescription = GetValue(nameof(PasteCommandDescription)); CopyWithHeaders = GetValue(nameof(CopyWithHeaders)); CopyWithHeadersCommandDescription = GetValue(nameof(CopyWithHeadersCommandDescription)); DatePickerPlaceholder = GetValue(nameof(DatePickerPlaceholder)); @@ -70,6 +72,8 @@ private static string GetValue(string name) public static string ClearSorting { get; set; } public static string Copy { get; set; } public static string CopyCommandDescription { get; set; } + public static string Paste { get; set; } + public static string PasteCommandDescription { get; set; } public static string CopyWithHeaders { get; set; } public static string CopyWithHeadersCommandDescription { get; set; } public static string DatePickerPlaceholder { get; set; } diff --git a/src/Themes/TableViewHeaderRow.xaml b/src/Themes/TableViewHeaderRow.xaml index 1456a69d..4b22fd56 100644 --- a/src/Themes/TableViewHeaderRow.xaml +++ b/src/Themes/TableViewHeaderRow.xaml @@ -142,6 +142,14 @@ ScopeOwner="{TemplateBinding TableView}" /> + + + + + Date: Thu, 4 Jun 2026 19:25:54 +0500 Subject: [PATCH 2/8] Add tests for value setter and SetClipboardContent logic --- tests/ObjectExtensionsTests.cs | 544 ++++++++++++++++++++++++++++- tests/TableViewBoundColumnTests.cs | 50 ++- 2 files changed, 592 insertions(+), 2 deletions(-) diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index e17ba9e8..493f9d28 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -1,7 +1,8 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections; using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Globalization; using WinUI.TableView.Extensions; namespace WinUI.TableView.Tests; @@ -303,6 +304,545 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForEmptyList() Assert.IsNull(result); } + [TestMethod] + public void GetCompiledValueSetter_ShouldSetSimpleProperty() + { + var testItem = new TestItem { Number = 7 }; + + var setter = testItem.GetCompiledValueSetter("Number"); + + Assert.IsNotNull(setter); + + setter(testItem, 42); + + Assert.AreEqual(42, testItem.Number); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetSimpleNullableProperty() + { + var today = DateTimeOffset.Now; + var testItem = new TestItem(); + + var setter = testItem.GetCompiledValueSetter("CompletedDate"); + + Assert.IsNotNull(setter); + + setter(testItem, today); + + Assert.AreEqual(today, testItem.CompletedDate); + + setter(testItem, null); + + Assert.IsNull(testItem.CompletedDate); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetNestedProperty() + { + var testItem = new TestItem + { + SubItems = [new() { SubSubItems = [new() { Name = "OldValue" }] }] + }; + + var setter = testItem.GetCompiledValueSetter("SubItems[0].SubSubItems[0].Name"); + + Assert.IsNotNull(setter); + + setter(testItem, "NewValue"); + + Assert.AreEqual("NewValue", testItem.SubItems[0].SubSubItems[0].Name); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetArrayElement() + { + var testItem = new TestItem { IntArray = [10, 20, 30] }; + + var setter = testItem.GetCompiledValueSetter("IntArray[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, 99); + + Assert.AreEqual(99, testItem.IntArray[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSet2DArrayElement() + { + var testItem = new TestItem + { + Int2DArray = new int[,] { { 1, 2, 3 }, { 10, 20, 30 } } + }; + + var setter = testItem.GetCompiledValueSetter("Int2DArray[1,1]"); + + Assert.IsNotNull(setter); + + setter(testItem, 99); + + Assert.AreEqual(99, testItem.Int2DArray[1, 1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetMultiDimensionalIndexer() + { + var testItem = new TestItem(); + + var setter = testItem.GetCompiledValueSetter("[2,foo]"); + + Assert.IsNotNull(setter); + + setter(testItem, "bar"); + + Assert.AreEqual("bar", testItem[2, "foo"]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetDictionaryByStringKey() + { + var testItem = new TestItem + { + Dictionary1 = new() { { "key1", "value1" } } + }; + + var setter = testItem.GetCompiledValueSetter("Dictionary1[key1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("updated", testItem.Dictionary1["key1"]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldAddDictionaryValue_WhenStringKeyDoesNotExist() + { + var testItem = new TestItem + { + Dictionary1 = [] + }; + + var setter = testItem.GetCompiledValueSetter("Dictionary1[key1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "value1"); + + Assert.AreEqual("value1", testItem.Dictionary1["key1"]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetDictionaryByIntKey() + { + var testItem = new TestItem + { + Dictionary2 = new() { { 1, "value1" } } + }; + + var setter = testItem.GetCompiledValueSetter("Dictionary2[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("updated", testItem.Dictionary2[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldAddDictionaryValue_WhenIntKeyDoesNotExist() + { + var testItem = new TestItem + { + Dictionary2 = [] + }; + + var setter = testItem.GetCompiledValueSetter("Dictionary2[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "value1"); + + Assert.AreEqual("value1", testItem.Dictionary2[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForInvalidProperty() + { + var testItem = new TestItem(); + + var setter = testItem.GetCompiledValueSetter("NonExistent.Property.Path"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForInvalidNestedProperty() + { + var testItem = new TestItem + { + SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] + }; + + var setter = testItem.GetCompiledValueSetter("SubItems[0].SubSubItems[0].Invalid"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForNullIntermediateProperty() + { + var testItem = new TestItem + { + SubItems = [new() { SubSubItems = null! }] + }; + + var setter = testItem.GetCompiledValueSetter("SubItems[0].SubSubItems[0].Name"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForInvalidDictionaryProperty() + { + var testItem = new TestItem(); + + var setter = testItem.GetCompiledValueSetter("Dictionary[123]"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotThrow_ForDictionaryKeyTypeMismatch() + { + var testItem = new TestItem + { + Dictionary1 = new() { { "key1", "value1" } } + }; + + var setter = testItem.GetCompiledValueSetter("Dictionary1[123]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("value1", testItem.Dictionary1["key1"]); + Assert.IsFalse(testItem.Dictionary1.ContainsKey("123")); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForOutOfBoundsArrayIndex() + { + var testItem = new TestItem { IntArray = [10, 20, 30] }; + + var setter = testItem.GetCompiledValueSetter("IntArray[5]"); + + Assert.IsNotNull(setter); + + setter(testItem, 99); + + CollectionAssert.AreEqual(new[] { 10, 20, 30 }, testItem.IntArray); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForOutOfBoundsMultiDimArrayIndex() + { + var testItem = new TestItem + { + Int2DArray = new int[,] { { 1, 2 }, { 10, 30 } } + }; + + var setter = testItem.GetCompiledValueSetter("Int2DArray[2,2]"); + + Assert.IsNotNull(setter); + + setter(testItem, 99); + + Assert.AreEqual(1, testItem.Int2DArray[0, 0]); + Assert.AreEqual(2, testItem.Int2DArray[0, 1]); + Assert.AreEqual(10, testItem.Int2DArray[1, 0]); + Assert.AreEqual(30, testItem.Int2DArray[1, 1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetListByIndex() + { + var testItem = new TestItem + { + StringList = ["item0", "item1", "item2"] + }; + + var setter = testItem.GetCompiledValueSetter("StringList[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("updated", testItem.StringList[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetNonGenericListByIndex() + { + var testItem = new TestItem + { + NonGenericList = new System.Collections.ArrayList { "item0", "item1", "item2" } + }; + + var setter = testItem.GetCompiledValueSetter("NonGenericList[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("updated", testItem.NonGenericList[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForNegativeListIndex() + { + var testItem = new TestItem { StringList = ["item0", "item1"] }; + + var setter = testItem.GetCompiledValueSetter("StringList[-1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + CollectionAssert.AreEqual(new[] { "item0", "item1" }, testItem.StringList); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForOutOfBoundsListIndex() + { + var testItem = new TestItem { StringList = ["item0"] }; + + var setter = testItem.GetCompiledValueSetter("StringList[5]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + CollectionAssert.AreEqual(new[] { "item0" }, testItem.StringList); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForOutOfBoundsNonGenericListIndex() + { + var testItem = new TestItem + { + NonGenericList = new System.Collections.ArrayList { "item0" } + }; + + var setter = testItem.GetCompiledValueSetter("NonGenericList[5]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual("item0", testItem.NonGenericList[0]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_ForEmptyList() + { + var testItem = new TestItem { StringList = [] }; + + var setter = testItem.GetCompiledValueSetter("StringList[0]"); + + Assert.IsNotNull(setter); + + setter(testItem, "updated"); + + Assert.AreEqual(0, testItem.StringList.Count); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForNegativeArrayIndex() + { + var testItem = new TestItem { IntArray = [10, 20, 30] }; + + var setter = testItem.GetCompiledValueSetter("IntArray[-1]"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForWrongArrayDimensions() + { + var testItem = new TestItem + { + Int2DArray = new int[,] { { 1, 2 }, { 3, 4 } } + }; + + var setter = testItem.GetCompiledValueSetter("Int2DArray[1]"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldReturnNull_ForReadOnlyProperty() + { + var testItem = new TestItem + { + StringList = ["item0", "item1 long text", "item2"] + }; + + var setter = testItem.GetCompiledValueSetter("StringList[1].Length"); + + Assert.IsNull(setter); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertStringToInt() + { + var testItem = new TestItem { Number = 7 }; + + var setter = testItem.GetCompiledValueSetter("Number"); + + Assert.IsNotNull(setter); + + setter(testItem, "42"); + + Assert.AreEqual(42, testItem.Number); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSet_WhenStringCannotConvertToInt() + { + var testItem = new TestItem { Number = 7 }; + + var setter = testItem.GetCompiledValueSetter("Number"); + + Assert.IsNotNull(setter); + + setter(testItem, "invalid"); + + Assert.AreEqual(7, testItem.Number); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertStringToNullableDateTimeOffset() + { + var testItem = new TestItem(); + + var setter = testItem.GetCompiledValueSetter("CompletedDate"); + + Assert.IsNotNull(setter); + + var value = DateTimeOffset.Now; + + setter(testItem, value.ToString(CultureInfo.CurrentCulture)); + + Assert.IsNotNull(testItem.CompletedDate); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldSetNullablePropertyToNull_WhenValueIsEmptyString() + { + var testItem = new TestItem + { + CompletedDate = DateTimeOffset.Now + }; + + var setter = testItem.GetCompiledValueSetter("CompletedDate"); + + Assert.IsNotNull(setter); + + setter(testItem, ""); + + Assert.IsNull(testItem.CompletedDate); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertStringToArrayIntElement() + { + var testItem = new TestItem + { + IntArray = [1, 2, 3] + }; + + var setter = testItem.GetCompiledValueSetter("IntArray[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "42"); + + Assert.AreEqual(42, testItem.IntArray[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldNotSetArrayIntElement_WhenConversionFails() + { + var testItem = new TestItem + { + IntArray = [1, 2, 3] + }; + + var setter = testItem.GetCompiledValueSetter("IntArray[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "not-a-number"); + + Assert.AreEqual(2, testItem.IntArray[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertStringToListIntElement() + { + var testItem = new TestItem + { + IntList = [1, 2, 3] + }; + + var setter = testItem.GetCompiledValueSetter("IntList[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, "99"); + + Assert.AreEqual(99, testItem.IntList[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertIntToStringListElement() + { + var testItem = new TestItem + { + StringList = ["A", "B", "C"] + }; + + var setter = testItem.GetCompiledValueSetter("StringList[1]"); + + Assert.IsNotNull(setter); + + setter(testItem, 123); + + Assert.AreEqual("123", testItem.StringList[1]); + } + + [TestMethod] + public void GetCompiledValueSetter_ShouldConvertStringToDictionaryIntValue() + { + var testItem = new TestItem + { + IntDictionary = new() + { + ["A"] = 1 + } + }; + + var setter = testItem.GetCompiledValueSetter("IntDictionary[A]"); + + Assert.IsNotNull(setter); + + setter(testItem, "500"); + + Assert.AreEqual(500, testItem.IntDictionary["A"]); + } + [TestMethod] public void GetItemType_ShouldReturnCorrectType_ForGenericEnumerable() { @@ -370,9 +910,11 @@ private class TestItem public List SubItems { get; set; } = []; public Dictionary Dictionary1 { get; set; } = []; public Dictionary Dictionary2 { get; set; } = []; + public Dictionary IntDictionary { get; set; } = []; public int[] IntArray { get; set; } = []; public int[,] Int2DArray { get; set; } = new int[0, 0]; public List StringList { get; set; } = []; + public List IntList { get; set; } = []; // Multi-dimensional indexer private readonly Dictionary<(int, string), string> _multiIndex = new(); diff --git a/tests/TableViewBoundColumnTests.cs b/tests/TableViewBoundColumnTests.cs index b3dd8d03..37c9fcbe 100644 --- a/tests/TableViewBoundColumnTests.cs +++ b/tests/TableViewBoundColumnTests.cs @@ -2,11 +2,12 @@ using Microsoft.UI.Xaml.Data; using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System; namespace WinUI.TableView.Tests; [TestClass] -public class TableViewBoundColumnTests +public partial class TableViewBoundColumnTests { [UITestMethod] public void TableViewBoundColumn_BindingAndOperationBinding_UseExpectedDefaults() @@ -39,4 +40,51 @@ public void TableViewBoundColumn_ClipboardContentBinding_FallsBackToBinding() Assert.AreSame(column.Binding, column.ClipboardContentBinding); Assert.AreEqual(nameof(ColumnTestItem.Name), column.ClipboardContentBinding!.Path!.Path); } + + [UITestMethod] + public void TableViewBoundColumn_SetClipboardContent_WritesBackThroughBindingPath() + { + var column = new TableViewTextColumn + { + Binding = new Binding { Path = new PropertyPath(nameof(ColumnTestItem.Name)) } + }; + var item = new ColumnTestItem { Name = "before" }; + + var success = column.SetClipboardContent(item, "after"); + + Assert.IsTrue(success); + Assert.AreEqual("after", item.Name); + } + + [UITestMethod] + public void TableViewBoundColumn_SetClipboardContent_UsesBindingConverterConvertBack() + { + var column = new TableViewTextColumn + { + Binding = new Binding + { + Path = new PropertyPath(nameof(ColumnTestItem.Name)), + Converter = new ConvertBackSuffixConverter() + } + }; + var item = new ColumnTestItem(); + + var success = column.SetClipboardContent(item, "value"); + + Assert.IsTrue(success); + Assert.AreEqual("value-converted", item.Name); + } + + private sealed partial class ConvertBackSuffixConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + return $"{value}-converted"; + } + } } From efad885ce5a5f77dda73474715115fd2eb627a17 Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Thu, 4 Jun 2026 19:26:23 +0500 Subject: [PATCH 3/8] Rename GetFuncCompiledPropertyPath to GetCompiledValueGetter --- src/Columns/TableViewColumn.cs | 28 ++++---- src/Extensions/ObjectExtensions.cs | 2 +- src/ItemsSource/SortDescription.cs | 8 +-- tests/ObjectExtensionsTests.cs | 112 ++++++++++++++--------------- 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/Columns/TableViewColumn.cs b/src/Columns/TableViewColumn.cs index 2e7a7dda..b0a2824c 100644 --- a/src/Columns/TableViewColumn.cs +++ b/src/Columns/TableViewColumn.cs @@ -15,9 +15,9 @@ namespace WinUI.TableView; [StyleTypedProperty(Property = nameof(CellStyle), StyleTargetType = typeof(TableViewCell))] public abstract partial class TableViewColumn : DependencyObject { - private Func? _funcCompiledPropertyPath; - private Func? _funcCompiledClipboardPropertyPath; - private Action? _compliedValueSetter; + private Func? _compliedValueGetter; + private Func? _compliedClipboardValueGetter; + private Action? _compliedClipboardValueSetter; /// /// Initializes a new instance of the class with default conditional cell styles. @@ -108,11 +108,11 @@ internal void SetOwningTableView(TableView tableView) if (dataItem is null) return null; - if (_funcCompiledPropertyPath is null && !string.IsNullOrWhiteSpace(OperationContentBindingPropertyPath)) - _funcCompiledPropertyPath = dataItem.GetFuncCompiledPropertyPath(OperationContentBindingPropertyPath!); + if (_compliedValueGetter is null && !string.IsNullOrWhiteSpace(OperationContentBindingPropertyPath)) + _compliedValueGetter = dataItem.GetCompiledValueGetter(OperationContentBindingPropertyPath!); - if (_funcCompiledPropertyPath is not null) - dataItem = _funcCompiledPropertyPath(dataItem); + if (_compliedValueGetter is not null) + dataItem = _compliedValueGetter(dataItem); if (OperationContentBinding?.Converter is not null) { @@ -136,11 +136,11 @@ internal void SetOwningTableView(TableView tableView) if (dataItem is null) return null; - if (_funcCompiledClipboardPropertyPath is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) - _funcCompiledClipboardPropertyPath = dataItem.GetFuncCompiledPropertyPath(ClipboardContentBindingPropertyPath!); + if (_compliedClipboardValueGetter is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) + _compliedClipboardValueGetter = dataItem.GetCompiledValueGetter(ClipboardContentBindingPropertyPath!); - if (_funcCompiledClipboardPropertyPath is not null) - dataItem = _funcCompiledClipboardPropertyPath(dataItem); + if (_compliedClipboardValueGetter is not null) + dataItem = _compliedClipboardValueGetter(dataItem); if (ClipboardContentBinding?.Converter is not null) { @@ -167,8 +167,8 @@ public virtual bool SetClipboardContent(object? dataItem, object? value) try { - if (_compliedValueSetter is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) - _compliedValueSetter = dataItem.GetCompiledValueSetter(ClipboardContentBindingPropertyPath!); + if (_compliedClipboardValueSetter is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) + _compliedClipboardValueSetter = dataItem.GetCompiledValueSetter(ClipboardContentBindingPropertyPath!); if (ClipboardContentBinding?.Converter is not null) { @@ -179,7 +179,7 @@ public virtual bool SetClipboardContent(object? dataItem, object? value) ClipboardContentBinding.ConverterLanguage); } - _compliedValueSetter?.Invoke(dataItem, value); + _compliedClipboardValueSetter?.Invoke(dataItem, value); return true; } catch (Exception ex) diff --git a/src/Extensions/ObjectExtensions.cs b/src/Extensions/ObjectExtensions.cs index 00041550..51fabd79 100644 --- a/src/Extensions/ObjectExtensions.cs +++ b/src/Extensions/ObjectExtensions.cs @@ -25,7 +25,7 @@ internal static partial class ObjectExtensions /// The data item instance to use for runtime type evaluation. /// The binding path to access, e.g. "[0].SubPropertyArray[0].SubSubProperty". /// A compiled function that takes an instance and returns the property value, or null if the property path is invalid. - internal static Func? GetFuncCompiledPropertyPath(this object dataItem, string bindingPath) + internal static Func? GetCompiledValueGetter(this object dataItem, string bindingPath) { try { diff --git a/src/ItemsSource/SortDescription.cs b/src/ItemsSource/SortDescription.cs index 08f07a9b..3c37dc97 100644 --- a/src/ItemsSource/SortDescription.cs +++ b/src/ItemsSource/SortDescription.cs @@ -10,7 +10,7 @@ namespace WinUI.TableView; /// public class SortDescription { - private Func? _funcCompiled; + private Func? _compiledValueGetter; /// /// Initializes a new instance of the class that describes @@ -42,10 +42,10 @@ public SortDescription(string? propertyName, if (ValueDelegate is not null) return ValueDelegate(item); - if (_funcCompiled is null && !string.IsNullOrWhiteSpace(PropertyName)) - _funcCompiled = item.GetFuncCompiledPropertyPath(PropertyName!); + if (_compiledValueGetter is null && !string.IsNullOrWhiteSpace(PropertyName)) + _compiledValueGetter = item.GetCompiledValueGetter(PropertyName!); - return _funcCompiled?.Invoke(item); + return _compiledValueGetter?.Invoke(item); } /// diff --git a/tests/ObjectExtensionsTests.cs b/tests/ObjectExtensionsTests.cs index 493f9d28..7f79967d 100644 --- a/tests/ObjectExtensionsTests.cs +++ b/tests/ObjectExtensionsTests.cs @@ -11,21 +11,21 @@ namespace WinUI.TableView.Tests; public class ObjectExtensionsTests { [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessSimpleProperty() + public void GetCompiledValueGetter_ShouldAccessSimpleProperty() { var testItem = new TestItem { Number = 7 }; - var func = testItem.GetFuncCompiledPropertyPath("Number"); + var func = testItem.GetCompiledValueGetter("Number"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual(7, result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessSimpleNullableProperty() + public void GetCompiledValueGetter_ShouldAccessSimpleNullableProperty() { var today = DateTimeOffset.Now; var testItem = new TestItem { CompletedDate = today }; - var func = testItem.GetFuncCompiledPropertyPath("CompletedDate"); + var func = testItem.GetCompiledValueGetter("CompletedDate"); Assert.IsNotNull(func); var result = func(testItem); @@ -37,103 +37,103 @@ public void GetFuncCompiledPropertyPath_ShouldAccessSimpleNullableProperty() } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessNestedProperty() + public void GetCompiledValueGetter_ShouldAccessNestedProperty() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Name"); + var func = testItem.GetCompiledValueGetter("SubItems[0].SubSubItems[0].Name"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("NestedValue", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessArrayElement() + public void GetCompiledValueGetter_ShouldAccessArrayElement() { var testItem = new TestItem { IntArray = [10, 20, 30] }; - var func = testItem.GetFuncCompiledPropertyPath("IntArray[1]"); + var func = testItem.GetCompiledValueGetter("IntArray[1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual(20, result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccess2DArrayElement() + public void GetCompiledValueGetter_ShouldAccess2DArrayElement() { var testItem = new TestItem { Int2DArray = new int[,] {{1, 2, 3}, {10, 20, 30}} }; - var func = testItem.GetFuncCompiledPropertyPath("Int2DArray[1,1]"); + var func = testItem.GetCompiledValueGetter("Int2DArray[1,1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual(20, result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessMultiDimensionalIndexer() + public void GetCompiledValueGetter_ShouldAccessMultiDimensionalIndexer() { var testItem = new TestItem(); testItem[2, "foo"] = "bar"; - var func = testItem.GetFuncCompiledPropertyPath("[2,foo]"); + var func = testItem.GetCompiledValueGetter("[2,foo]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("bar", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessDictionaryByStringKey() + public void GetCompiledValueGetter_ShouldAccessDictionaryByStringKey() { var testItem = new TestItem { Dictionary1 = new() { { "key1", "value1" } } }; - var func = testItem.GetFuncCompiledPropertyPath("Dictionary1[key1]"); + var func = testItem.GetCompiledValueGetter("Dictionary1[key1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("value1", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessDictionaryByIntKey() + public void GetCompiledValueGetter_ShouldAccessDictionaryByIntKey() { var testItem = new TestItem { Dictionary2 = new() { { 1, "value1" } } }; - var func = testItem.GetFuncCompiledPropertyPath("Dictionary2[1]"); + var func = testItem.GetCompiledValueGetter("Dictionary2[1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("value1", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidProperty() { var testItem = new TestItem(); - var func = testItem.GetFuncCompiledPropertyPath("NonExistent.Property.Path"); + var func = testItem.GetCompiledValueGetter("NonExistent.Property.Path"); Assert.IsNull(func); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty2() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidProperty2() { var testItem = new TestItem(); - var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Invalid"); + var func = testItem.GetCompiledValueGetter("SubItems[0].SubSubItems[0].Invalid"); Assert.IsNull(func); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty3() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidProperty3() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Invalid"); + var func = testItem.GetCompiledValueGetter("SubItems[0].SubSubItems[0].Invalid"); Assert.IsNull(func); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidProperty4() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidProperty4() { var testItem = new TestItem(); - var func = testItem.GetFuncCompiledPropertyPath("Dictionary[123]"); + var func = testItem.GetCompiledValueGetter("Dictionary[123]"); Assert.IsNull(func); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidDictionaryIndexer() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidDictionaryIndexer() { var testItem = new TestItem { Dictionary2 = new() { { 1, "value1" } } }; - var func = testItem.GetFuncCompiledPropertyPath("Dictionary2[1]"); + var func = testItem.GetCompiledValueGetter("Dictionary2[1]"); Assert.IsNotNull(func); var result = func(testItem); @@ -145,10 +145,10 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidDictionaryInd } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidArrayIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForInvalidArrayIndex() { var testItem = new TestItem { SubItems = [new() { SubSubItems = [new() { Name = "NestedValue" }] }] }; - var func = testItem.GetFuncCompiledPropertyPath("SubItems[0].SubSubItems[0].Name"); + var func = testItem.GetCompiledValueGetter("SubItems[0].SubSubItems[0].Name"); Assert.IsNotNull(func); var result = func(testItem); @@ -160,10 +160,10 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForInvalidArrayIndex() } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsArrayIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForOutOfBoundsArrayIndex() { var testItem = new TestItem { IntArray = [10, 20, 30] }; - var func = testItem.GetFuncCompiledPropertyPath("IntArray[2]"); + var func = testItem.GetCompiledValueGetter("IntArray[2]"); Assert.IsNotNull(func); var testItem2 = new TestItem { IntArray = [1] }; var result = func(testItem2); @@ -171,10 +171,10 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsArrayInde } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsMultiDimArrayIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForOutOfBoundsMultiDimArrayIndex() { var testItem3x3 = new TestItem { Int2DArray = new int[,] { { 1, 2, 3 }, { 10, 20, 30 } } }; - var func = testItem3x3.GetFuncCompiledPropertyPath("Int2DArray[2,2]"); + var func = testItem3x3.GetCompiledValueGetter("Int2DArray[2,2]"); Assert.IsNotNull(func); var result = func(testItem3x3); @@ -184,30 +184,30 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsMultiDimA } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessListByIndex() + public void GetCompiledValueGetter_ShouldAccessListByIndex() { var testItem = new TestItem { StringList = ["item0", "item1", "item2"] }; - var func = testItem.GetFuncCompiledPropertyPath("StringList[1]"); + var func = testItem.GetCompiledValueGetter("StringList[1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("item1", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessPropertyOnString() + public void GetCompiledValueGetter_ShouldAccessPropertyOnString() { var testItem = new TestItem { StringList = ["item0", "item1 long text", "item2"] }; - var func = testItem.GetFuncCompiledPropertyPath("StringList[1].Length"); + var func = testItem.GetCompiledValueGetter("StringList[1].Length"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual(15, result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsListIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForOutOfBoundsListIndex() { var testItem = new TestItem { StringList = ["item0", "item1", "item2"] }; - var func = testItem.GetFuncCompiledPropertyPath("StringList[2]"); + var func = testItem.GetCompiledValueGetter("StringList[2]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("item2", result); @@ -219,86 +219,86 @@ public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsListIndex } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForDictionaryKeyTypeMismatch() + public void GetCompiledValueGetter_ShouldReturnNull_ForDictionaryKeyTypeMismatch() { // Dictionary accessed with int key var testItem = new TestItem { Dictionary1 = new() { { "key1", "value1" } } }; - var func = testItem.GetFuncCompiledPropertyPath("Dictionary1[123]"); // int key for string-keyed dictionary + var func = testItem.GetCompiledValueGetter("Dictionary1[123]"); // int key for string-keyed dictionary Assert.IsNotNull(func); var result = func(testItem); Assert.IsNull(result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessValueTypeProperty() + public void GetCompiledValueGetter_ShouldAccessValueTypeProperty() { var testItem = new TestItem { ValueTypeStruct = new TestStruct { Value = 42 } }; - var func = testItem.GetFuncCompiledPropertyPath("ValueTypeStruct.Value"); + var func = testItem.GetCompiledValueGetter("ValueTypeStruct.Value"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual(42, result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForNegativeArrayIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForNegativeArrayIndex() { var testItem = new TestItem { IntArray = [10, 20, 30] }; - var func = testItem.GetFuncCompiledPropertyPath("IntArray[-1]"); + var func = testItem.GetCompiledValueGetter("IntArray[-1]"); Assert.IsNull(func); // Should fail during expression building } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForNegativeListIndex() + public void GetCompiledValueGetter_ShouldReturnNull_ForNegativeListIndex() { var testItem = new TestItem { StringList = ["item0", "item1"] }; - var func = testItem.GetFuncCompiledPropertyPath("StringList[-1]"); + var func = testItem.GetCompiledValueGetter("StringList[-1]"); Assert.IsNull(func); // Should fail during expression building } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForWrongArrayDimensions() + public void GetCompiledValueGetter_ShouldReturnNull_ForWrongArrayDimensions() { var testItem = new TestItem { Int2DArray = new int[,] { { 1, 2 }, { 3, 4 } } }; - var func = testItem.GetFuncCompiledPropertyPath("Int2DArray[1]"); // 2D array with 1D index + var func = testItem.GetCompiledValueGetter("Int2DArray[1]"); // 2D array with 1D index Assert.IsNull(func); // Should fail during expression building } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForThrowingIndexer() + public void GetCompiledValueGetter_ShouldReturnNull_ForThrowingIndexer() { var testItem = new TestItem(); // This should trigger the generic indexer path with try-catch - var func = testItem.GetFuncCompiledPropertyPath("[999,nonexistent]"); + var func = testItem.GetCompiledValueGetter("[999,nonexistent]"); Assert.IsNotNull(func); var result = func(testItem); Assert.IsNull(result); // Custom indexer returns empty string, but expression should handle gracefully } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldAccessNonGenericList() + public void GetCompiledValueGetter_ShouldAccessNonGenericList() { var testItem = new TestItem { NonGenericList = new System.Collections.ArrayList { "item0", "item1", "item2" } }; - var func = testItem.GetFuncCompiledPropertyPath("NonGenericList[1]"); + var func = testItem.GetCompiledValueGetter("NonGenericList[1]"); Assert.IsNotNull(func); var result = func(testItem); Assert.AreEqual("item1", result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForOutOfBoundsNonGenericList() + public void GetCompiledValueGetter_ShouldReturnNull_ForOutOfBoundsNonGenericList() { var testItem = new TestItem { NonGenericList = new System.Collections.ArrayList { "item0" } }; - var func = testItem.GetFuncCompiledPropertyPath("NonGenericList[5]"); + var func = testItem.GetCompiledValueGetter("NonGenericList[5]"); Assert.IsNotNull(func); var result = func(testItem); Assert.IsNull(result); } [TestMethod] - public void GetFuncCompiledPropertyPath_ShouldReturnNull_ForEmptyList() + public void GetCompiledValueGetter_ShouldReturnNull_ForEmptyList() { var testItem = new TestItem { StringList = [] }; - var func = testItem.GetFuncCompiledPropertyPath("StringList[0]"); + var func = testItem.GetCompiledValueGetter("StringList[0]"); Assert.IsNotNull(func); var result = func(testItem); Assert.IsNull(result); From 5829fe15c2fb97f94241db0722d71020107f9e7f Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Thu, 4 Jun 2026 20:03:12 +0500 Subject: [PATCH 4/8] add ClipboardActionsPage sample --- .../MainPage.xaml.cs | 1 + .../Pages/ClipboardActionsPage.xaml | 31 +++++++++++++++++++ .../Pages/ClipboardActionsPage.xaml.cs | 14 +++++++++ 3 files changed, 46 insertions(+) create mode 100644 samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml create mode 100644 samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml.cs diff --git a/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs b/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs index f5414d02..5d0b5e8e 100644 --- a/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs +++ b/samples/WinUI.TableView.SampleApp/MainPage.xaml.cs @@ -108,6 +108,7 @@ private void OnNavigationSelectionChanged(NavigationView sender, NavigationViewS "Filtering" => typeof(FilteringPage), "Customize Filter Flyout" => typeof(CustomizeFilterPage), "External Filtering" => typeof(ExternalFilteringPage), + "Clipboard Actions" => typeof(ClipboardActionsPage), "Editing" => typeof(EditingPage), "Sorting" => typeof(SortingPage), "Custom Sorting" => typeof(CustomizeSortingPage), diff --git a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml new file mode 100644 index 00000000..ac8b9f42 --- /dev/null +++ b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml @@ -0,0 +1,31 @@ + + + + + + Use Ctrl+C, Ctrl+Shift+V or the corner options menu to copy the current selection. +Paste tab-delimited data from Excel with Ctrl+V or the same menu. + + + + + + +<tv:TableView x:Name="tableView" + ItemsSource="{Binding Items}" /> + + + + + diff --git a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml.cs b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml.cs new file mode 100644 index 00000000..918046c8 --- /dev/null +++ b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml.cs @@ -0,0 +1,14 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Collections.ObjectModel; +using Windows.ApplicationModel.DataTransfer; + +namespace WinUI.TableView.SampleApp.Pages; + +public sealed partial class ClipboardActionsPage : Page +{ + public ClipboardActionsPage() + { + InitializeComponent(); + } +} From adc4bb32363fc398d9afb402b323553b17a6cda9 Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Fri, 5 Jun 2026 19:18:41 +0500 Subject: [PATCH 5/8] Add clipboard paste event and CanCopy/CanPaste properties --- .../TableViewPasteFromClipboardEventArgs.cs | 11 +++++++ src/TableView.Events.cs | 14 ++++++++ src/TableView.Paste.cs | 17 ++++++---- src/TableView.Properties.cs | 28 ++++++++++++++++ src/TableView.cs | 2 +- src/TableViewHeaderRow.OptionComamnds.cs | 32 ++++++++++--------- 6 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 src/EventArgs/TableViewPasteFromClipboardEventArgs.cs diff --git a/src/EventArgs/TableViewPasteFromClipboardEventArgs.cs b/src/EventArgs/TableViewPasteFromClipboardEventArgs.cs new file mode 100644 index 00000000..ef48a5c0 --- /dev/null +++ b/src/EventArgs/TableViewPasteFromClipboardEventArgs.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace WinUI.TableView; + +/// +/// Provides data for the event. +/// +public class TableViewPasteFromClipboardEventArgs : HandledEventArgs +{ + +} diff --git a/src/TableView.Events.cs b/src/TableView.Events.cs index aa1dc8ff..47868c32 100644 --- a/src/TableView.Events.cs +++ b/src/TableView.Events.cs @@ -65,6 +65,20 @@ protected virtual void OnCopyToClipboard(TableViewCopyToClipboardEventArgs args) CopyToClipboard?.Invoke(this, args); } + /// + /// Event triggered when pasting clipboard content into the TableView. + /// + public event EventHandler? PasteFromClipboard; + + /// + /// Called before the event occurs. + /// + /// Handleable event args. + protected virtual void OnPasteFromClipboard(TableViewPasteFromClipboardEventArgs args) + { + PasteFromClipboard?.Invoke(this, args); + } + /// /// Event triggered when the IsReadOnly property changes. /// diff --git a/src/TableView.Paste.cs b/src/TableView.Paste.cs index a32972c4..f6e248fe 100644 --- a/src/TableView.Paste.cs +++ b/src/TableView.Paste.cs @@ -21,19 +21,22 @@ public partial class TableView /// True if the paste operation was initiated successfully; otherwise, false. internal bool TryStartPasteFromClipboard() { - if (!CanHandlePasteRequest()) + var args = new TableViewPasteFromClipboardEventArgs(); + OnPasteFromClipboard(args); + + if (args.Handled || !CanHandlePasteRequest()) { return false; } - PasteFromClipboard(); + PasteFromClipboardAsync(); return true; } /// /// Handles the actual paste operation from the clipboard. /// - private async void PasteFromClipboard() + private async void PasteFromClipboardAsync() { try { @@ -143,15 +146,15 @@ private static IReadOnlyList ParseClipboardText(string? text) /// Determines whether the TableView can handle a paste request based on its current state. /// /// True if the TableView can handle the paste request; otherwise, false. - private bool CanHandlePasteRequest() + internal bool CanHandlePasteRequest() { - if (IsReadOnly || IsEditing || Columns.VisibleColumns.Count == 0 || Items.Count == 0) + var focused = FocusManager.GetFocusedElement(XamlRoot!) as FrameworkElement; + if (focused is TextBox or PasswordBox or RichEditBox) { return false; } - var focused = FocusManager.GetFocusedElement(XamlRoot!) as FrameworkElement; - return focused is not TextBox and not PasswordBox and not RichEditBox; + return CanPaste && !IsReadOnly && !IsEditing && Columns.VisibleColumns.Count != 0 && Items.Count != 0; } /// diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index f782956a..dc75982e 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -266,6 +266,34 @@ public partial class TableView /// public static readonly DependencyProperty ShowFilterItemsCountProperty = DependencyProperty.Register(nameof(ShowFilterItemsCount), typeof(bool), typeof(TableView), new PropertyMetadata(false)); + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty CanCopyProperty = DependencyProperty.Register(nameof(CanCopy), typeof(bool), typeof(TableView), new PropertyMetadata(true)); + + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty CanPasteProperty = DependencyProperty.Register(nameof(CanPaste), typeof(bool), typeof(TableView), new PropertyMetadata(true)); + + /// + /// Gets or sets a value that indicates whether users can copy selected cells or rows to the clipboard. + /// + public bool CanCopy + { + get => (bool)GetValue(CanCopyProperty); + set => SetValue(CanCopyProperty, value); + } + + /// + /// Gets or sets a value that indicates whether users can paste clipboard data into the TableView. + /// + public bool CanPaste + { + get => (bool)GetValue(CanPasteProperty); + set => SetValue(CanPasteProperty, value); + } + /// /// Gets or sets a value indicating whether opening the column filter over header right-click is enabled. /// diff --git a/src/TableView.cs b/src/TableView.cs index 9835a65e..28c83ff1 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -514,7 +514,7 @@ internal void CopyToClipboardInternal(bool includeHeaders) var args = new TableViewCopyToClipboardEventArgs(includeHeaders); OnCopyToClipboard(args); - if (args.Handled) + if (!CanCopy || args.Handled) { return; } diff --git a/src/TableViewHeaderRow.OptionComamnds.cs b/src/TableViewHeaderRow.OptionComamnds.cs index 71fdafaa..19fcabdb 100644 --- a/src/TableViewHeaderRow.OptionComamnds.cs +++ b/src/TableViewHeaderRow.OptionComamnds.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using Windows.ApplicationModel.DataTransfer; namespace WinUI.TableView; @@ -39,17 +40,17 @@ private void SetOptionCommands() if (GetTemplateChild("SelectAllMenuItem") is MenuFlyoutItem selectAllMenuItem) selectAllMenuItem.Command = _selectAllCommand; - if (GetTemplateChild("ClearSelectionMenuItem") is MenuFlyoutItem clearSelectionMenuItem) + if (GetTemplateChild("ClearSelectionMenuItem") is MenuFlyoutItem clearSelectionMenuItem) clearSelectionMenuItem.Command = _deselectAllCommand; - if (GetTemplateChild("CopyMenuItem") is MenuFlyoutItem copyMenuItem) + if (GetTemplateChild("CopyMenuItem") is MenuFlyoutItem copyMenuItem) copyMenuItem.Command = _copyCommand; if (GetTemplateChild("PasteMenuItem") is MenuFlyoutItem pasteMenuItem) pasteMenuItem.Command = _pasteCommand; - if (GetTemplateChild("CopyWithHeadersMenuItem") is MenuFlyoutItem copyWithHeadersMenuItem) + if (GetTemplateChild("CopyWithHeadersMenuItem") is MenuFlyoutItem copyWithHeadersMenuItem) copyWithHeadersMenuItem.Command = _copyWithHeadersCommand; - if (GetTemplateChild("ClearSortingMenuItem") is MenuFlyoutItem clearSortingMenuItem) + if (GetTemplateChild("ClearSortingMenuItem") is MenuFlyoutItem clearSortingMenuItem) clearSortingMenuItem.Command = _clearSortingCommand; - if (GetTemplateChild("ClearFilterMenuItem") is MenuFlyoutItem clearFilterMenuItem) + if (GetTemplateChild("ClearFilterMenuItem") is MenuFlyoutItem clearFilterMenuItem) clearFilterMenuItem.Command = _clearFilterCommand; if (GetTemplateChild("ExportAllMenuItem") is MenuFlyoutItem exportAllMenuItem) { @@ -91,7 +92,7 @@ private void InitializeCommands() _copyWithHeadersCommand.Description = TableViewLocalizedStrings.CopyWithHeadersCommandDescription; _copyWithHeadersCommand.ExecuteRequested += delegate { TableView?.CopyToClipboardInternal(true); }; - _copyWithHeadersCommand.CanExecuteRequested += CanExecuteCopyWithHeadersCommand; + _copyWithHeadersCommand.CanExecuteRequested += CanExecuteCopyCommand; _clearSortingCommand.ExecuteRequested += delegate { TableView?.ClearAllSortingWithEvent(); }; _clearSortingCommand.CanExecuteRequested += CanExecuteClearSortingCommand; @@ -129,19 +130,20 @@ private void ExecuteCopyCommand(XamlUICommand sender, ExecuteRequestedEventArgs private void CanExecuteCopyCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) { - e.CanExecute = TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true; - } - - private void CanExecuteCopyWithHeadersCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) - { - e.CanExecute = TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true; + e.CanExecute = TableView?.CanCopy is true + && (TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true); } private void CanExecutePasteCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) { - e.CanExecute = TableView?.IsEditing is false - && TableView?.IsReadOnly is false - && (TableView?.SelectedItems.Count > 0 || TableView?.SelectedCells.Count > 0 || TableView?.CurrentCellSlot.HasValue is true); + e.CanExecute = false; + var canExecute = TableView?.CanPaste is true && TableView?.IsEditing is false && TableView?.IsReadOnly is false; + + if (canExecute) + { + var content = Clipboard.GetContent(); + e.CanExecute = canExecute && content.Contains(StandardDataFormats.Text); + } } private void CanExecuteClearSortingCommand(XamlUICommand sender, CanExecuteRequestedEventArgs e) From c6dd8273e47325c10f2017f611643e1e57062477 Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Fri, 5 Jun 2026 19:32:21 +0500 Subject: [PATCH 6/8] Update ClipboardActionsPage --- .../Pages/ClipboardActionsPage.xaml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml index ac8b9f42..3f11aa94 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml @@ -17,15 +17,35 @@ Paste tab-delimited data from Excel with Ctrl+V or the same menu. + + + + + + <tv:TableView x:Name="tableView" + CanCopy="$(CanCopy)" + CanPaste="$(CanPaste)" ItemsSource="{Binding Items}" /> + + + + From 996b4befde886ee22f8b86426ad33014e6a52ecb Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Fri, 5 Jun 2026 20:08:29 +0500 Subject: [PATCH 7/8] Add Paste command localization --- src/Strings/de-DE/WinUI.TableView.resw | 6 ++++++ src/Strings/en-US/WinUI.TableView.resw | 2 +- src/Strings/es-ES/WinUI.TableView.resw | 6 ++++++ src/Strings/ja-JP/WinUI.TableView.resw | 6 ++++++ src/Strings/pt-BR/WinUI.TableView.resw | 6 ++++++ src/Strings/ru-RU/WinUI.TableView.resw | 6 ++++++ src/Strings/sk-SK/WinUI.TableView.resw | 6 ++++++ src/Strings/zh-CN/WinUI.TableView.resw | 6 ++++++ src/Strings/zh-TW/WinUI.TableView.resw | 6 ++++++ 9 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Strings/de-DE/WinUI.TableView.resw b/src/Strings/de-DE/WinUI.TableView.resw index 8a1b5f89..c82f3549 100644 --- a/src/Strings/de-DE/WinUI.TableView.resw +++ b/src/Strings/de-DE/WinUI.TableView.resw @@ -180,4 +180,10 @@ Kopieren + + Einfügen + + + Fügt den Inhalt der Zwischenablage in die Zellen ein. + \ No newline at end of file diff --git a/src/Strings/en-US/WinUI.TableView.resw b/src/Strings/en-US/WinUI.TableView.resw index 11fddb2c..042c83e0 100644 --- a/src/Strings/en-US/WinUI.TableView.resw +++ b/src/Strings/en-US/WinUI.TableView.resw @@ -184,6 +184,6 @@ Paste - Paste clipboard content into the selected cells. + Paste clipboard content into the cells. \ No newline at end of file diff --git a/src/Strings/es-ES/WinUI.TableView.resw b/src/Strings/es-ES/WinUI.TableView.resw index e096dd02..a4402d6f 100644 --- a/src/Strings/es-ES/WinUI.TableView.resw +++ b/src/Strings/es-ES/WinUI.TableView.resw @@ -180,4 +180,10 @@ Seleccionar una hora + + Pegar + + + Pega el contenido del portapapeles en las celdas. + \ No newline at end of file diff --git a/src/Strings/ja-JP/WinUI.TableView.resw b/src/Strings/ja-JP/WinUI.TableView.resw index 5418b68f..58ebbee4 100644 --- a/src/Strings/ja-JP/WinUI.TableView.resw +++ b/src/Strings/ja-JP/WinUI.TableView.resw @@ -188,4 +188,10 @@ コピー + + 貼り付け + + + クリップボードのコンテンツをセルに貼り付けます。 + \ No newline at end of file diff --git a/src/Strings/pt-BR/WinUI.TableView.resw b/src/Strings/pt-BR/WinUI.TableView.resw index 7cf1cad5..d0588b58 100644 --- a/src/Strings/pt-BR/WinUI.TableView.resw +++ b/src/Strings/pt-BR/WinUI.TableView.resw @@ -180,4 +180,10 @@ Copiar + + Colar + + + Cola o conteúdo da área de transferência nas células. + \ No newline at end of file diff --git a/src/Strings/ru-RU/WinUI.TableView.resw b/src/Strings/ru-RU/WinUI.TableView.resw index 485765f2..5a84c760 100644 --- a/src/Strings/ru-RU/WinUI.TableView.resw +++ b/src/Strings/ru-RU/WinUI.TableView.resw @@ -180,4 +180,10 @@ Копировать + + Вставить + + + Вставить содержимое буфера обмена в ячейки. + \ No newline at end of file diff --git a/src/Strings/sk-SK/WinUI.TableView.resw b/src/Strings/sk-SK/WinUI.TableView.resw index 46ff6b4e..c3f20d0c 100644 --- a/src/Strings/sk-SK/WinUI.TableView.resw +++ b/src/Strings/sk-SK/WinUI.TableView.resw @@ -180,4 +180,10 @@ Kopírovať + + Prilepiť + + + Prilepiť obsah schránky do buniek. + \ No newline at end of file diff --git a/src/Strings/zh-CN/WinUI.TableView.resw b/src/Strings/zh-CN/WinUI.TableView.resw index 4b59ce90..7bd6ff81 100644 --- a/src/Strings/zh-CN/WinUI.TableView.resw +++ b/src/Strings/zh-CN/WinUI.TableView.resw @@ -180,4 +180,10 @@ 复制 + + 粘贴 + + + 将剪贴板内容粘贴到单元格中。 + \ No newline at end of file diff --git a/src/Strings/zh-TW/WinUI.TableView.resw b/src/Strings/zh-TW/WinUI.TableView.resw index 9f6fccfd..57fc68e6 100644 --- a/src/Strings/zh-TW/WinUI.TableView.resw +++ b/src/Strings/zh-TW/WinUI.TableView.resw @@ -180,4 +180,10 @@ 複製 + + 貼上 + + + 將剪貼簿內容貼上到儲存格中。 + \ No newline at end of file From f49a0e7f4064214c87608c7ae16c8036421e0f46 Mon Sep 17 00:00:00 2001 From: Waheed Ahmad Date: Tue, 9 Jun 2026 16:17:49 +0500 Subject: [PATCH 8/8] Potential fix for pull request finding --- .../Pages/ClipboardActionsPage.xaml | 2 +- src/Columns/TableViewColumn.cs | 5 ++++- src/TableView.Paste.cs | 10 +++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml index 3f11aa94..69f60b8e 100644 --- a/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml +++ b/samples/WinUI.TableView.SampleApp/Pages/ClipboardActionsPage.xaml @@ -12,7 +12,7 @@ - Use Ctrl+C, Ctrl+Shift+V or the corner options menu to copy the current selection. + Use Ctrl+C, Ctrl+Shift+C or the corner options menu to copy the current selection. Paste tab-delimited data from Excel with Ctrl+V or the same menu. diff --git a/src/Columns/TableViewColumn.cs b/src/Columns/TableViewColumn.cs index b0a2824c..572c71dc 100644 --- a/src/Columns/TableViewColumn.cs +++ b/src/Columns/TableViewColumn.cs @@ -170,6 +170,9 @@ public virtual bool SetClipboardContent(object? dataItem, object? value) if (_compliedClipboardValueSetter is null && !string.IsNullOrWhiteSpace(ClipboardContentBindingPropertyPath)) _compliedClipboardValueSetter = dataItem.GetCompiledValueSetter(ClipboardContentBindingPropertyPath!); + if (_compliedClipboardValueSetter is null) + return false; + if (ClipboardContentBinding?.Converter is not null) { value = ClipboardContentBinding.Converter.ConvertBack( @@ -179,7 +182,7 @@ public virtual bool SetClipboardContent(object? dataItem, object? value) ClipboardContentBinding.ConverterLanguage); } - _compliedClipboardValueSetter?.Invoke(dataItem, value); + _compliedClipboardValueSetter(dataItem, value); return true; } catch (Exception ex) diff --git a/src/TableView.Paste.cs b/src/TableView.Paste.cs index f6e248fe..e1d3c720 100644 --- a/src/TableView.Paste.cs +++ b/src/TableView.Paste.cs @@ -38,6 +38,7 @@ internal bool TryStartPasteFromClipboard() /// private async void PasteFromClipboardAsync() { + var previousAllowLiveShaping = _collectionView.AllowLiveShaping; try { var content = Clipboard.GetContent(); @@ -59,10 +60,11 @@ private async void PasteFromClipboardAsync() RefreshView(); } - _collectionView.AllowLiveShaping = true; + _collectionView.AllowLiveShaping = previousAllowLiveShaping; } catch (Exception ex) { + _collectionView.AllowLiveShaping = previousAllowLiveShaping; Debug.WriteLine($"TableView: Clipboard.GetText failed: {ex}"); } } @@ -109,9 +111,11 @@ private bool PasteClipboardTextInternal(string? text) var column = Columns.VisibleColumns[columnIndex]; var value = values[valueIndex]; - if (column.IsReadOnly) continue; + if (!column.IsReadOnly) + { + pastedAnyValue |= column.SetClipboardContent(item, value); + } - pastedAnyValue = column.SetClipboardContent(item, value); columnIndex++; } }