Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ public class Employee : INotifyPropertyChanged
<core:DataGridColumn Header="City" PropertyName="Address.City" Width="120" />
```

Accessors are compiled once via `Expression.Property` chains and cached.
The `PropertyInfo` chain is resolved once via reflection and captured in a closure that is cached per `"{TypeName}.{propertyPath}"` key — subsequent accesses on the same column are allocation-free.

---

Expand Down Expand Up @@ -655,6 +655,56 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for an in-depth contributor gui

---

## 🔧 NativeAOT & Trimming

`KumikoUI.Core` and `KumikoUI.SkiaSharp` are declared `<IsAotCompatible>true</IsAotCompatible>`. The trimmer and AOT analyzers run on both libraries at build time.

### Default behaviour (reflection-based)

Column binding via `PropertyName` / `DisplayMemberPath` uses a `PropertyInfo`-based accessor that is cached on first use. This path is annotated `[RequiresUnreferencedCode]` and will produce a trimmer warning in a trimmed/AOT app. The reflection itself is safe at runtime as long as the data item's public properties are not trimmed away.

### AOT-safe delegate path

Provide delegates instead of string property paths and **no reflection is used at all**:

```csharp
// DataGridColumn — fully AOT-safe getter + setter
var nameColumn = new DataGridColumn
{
Header = "Name",
ValueAccessor = item => ((Employee)item).Name,
ValueSetter = (item, v) => ((Employee)item).Name = (string?)v,
};

// DrawnComboBox — fully AOT-safe display/value selectors
comboBox.DisplaySelector = item => ((Department)item).Name;
comboBox.ValueSelector = item => ((Department)item).Id;
comboBox.SetItemsFromSource(departments);
```

When `ValueAccessor` is set, all internal paths — sorting, filtering, grouping, summaries, auto-fit — use the delegate directly. `ValueSetter` is used by `SetCellValue` (inline cell editing write-back).

`DisplaySelector` / `ValueSelector` on `DrawnComboBox` are checked before `DisplayMemberPath` / `ValueMemberPath`, so they can be mixed with existing string-based columns.

### Trimming guidance

If you continue to use `PropertyName`-based binding in a trimmed app, preserve the public properties of your data types — for example in the project file:

```xml
<TrimmerRootDescriptor Include="TrimmerRoots.xml" />
```

```xml
<!-- TrimmerRoots.xml -->
<linker>
<assembly fullname="MyApp">
<type fullname="MyApp.Models.Employee" preserve="all" />
</assembly>
</linker>
```

---

## 📦 Requirements

| Target | Minimum version |
Expand Down
50 changes: 42 additions & 8 deletions src/KumikoUI.Core/Components/DrawnComboBox.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using KumikoUI.Core.Input;
using KumikoUI.Core.Models;
using KumikoUI.Core.Rendering;
Expand Down Expand Up @@ -121,6 +122,28 @@
/// </summary>
public string? ValueMemberPath { get; set; }

/// <summary>
/// AOT-safe alternative to <see cref="DisplayMemberPath"/>.
/// When set, <see cref="SetItemsFromSource"/> calls this delegate instead of using reflection.
/// <example>
/// <code>
/// combo.DisplaySelector = item => ((Country)item).Name;
/// </code>
/// </example>
/// </summary>
public Func<object, string>? DisplaySelector { get; set; }

/// <summary>
/// AOT-safe alternative to <see cref="ValueMemberPath"/>.
/// When set, <see cref="SetItemsFromSource"/> calls this delegate instead of using reflection.
/// <example>
/// <code>
/// combo.ValueSelector = item => ((Country)item).Code;
/// </code>
/// </example>
/// </summary>
public Func<object, object?>? ValueSelector { get; set; }

// ── Events ──────────────────────────────────────────────────

/// <summary>Raised when the selected item changes.</summary>
Expand Down Expand Up @@ -169,9 +192,11 @@
}

/// <summary>
/// Populate items from a data source using DisplayMemberPath and ValueMemberPath.
/// Falls back to ToString() if paths are not set.
/// Populate items from a data source.
/// Prefers <see cref="DisplaySelector"/>/<see cref="ValueSelector"/> (AOT-safe) when set;
/// otherwise falls back to <see cref="DisplayMemberPath"/>/<see cref="ValueMemberPath"/> via reflection.
/// </summary>
[RequiresUnreferencedCode("Accesses properties on data item types by name via DisplayMemberPath/ValueMemberPath. Ensure the public properties of your data types are preserved when trimming, or use DisplaySelector/ValueSelector instead.")]
public void SetItemsFromSource(System.Collections.IEnumerable source)
Comment on lines +195 to 200
{
_items.Clear();
Expand All @@ -182,13 +207,21 @@
{
if (item == null) continue;

string displayText = DisplayMemberPath != null
? GetPropertyValue(item, DisplayMemberPath)?.ToString() ?? string.Empty
: item.ToString() ?? string.Empty;
string displayText;
if (DisplaySelector != null)
displayText = DisplaySelector(item);
else if (DisplayMemberPath != null)
displayText = GetPropertyValue(item, DisplayMemberPath)?.ToString() ?? string.Empty;
else
displayText = item.ToString() ?? string.Empty;

object? value = ValueMemberPath != null
? GetPropertyValue(item, ValueMemberPath)
: item;
object? value;
if (ValueSelector != null)
value = ValueSelector(item);
else if (ValueMemberPath != null)
value = GetPropertyValue(item, ValueMemberPath);
else
value = item;

_items.Add(new ComboBoxItem(displayText, value));
}
Expand All @@ -212,6 +245,7 @@
SelectedIndex = -1;
}

[RequiresUnreferencedCode("Accesses properties on data item types by name. Ensure the public properties of your data types are preserved when trimming.")]
private static object? GetPropertyValue(object obj, string propertyPath)
{
object? current = obj;
Expand Down Expand Up @@ -574,7 +608,7 @@
InvalidateVisual();
}

protected override void OnLostFocus()

Check warning on line 611 in src/KumikoUI.Core/Components/DrawnComboBox.cs

View workflow job for this annotation

GitHub Actions / Build & Test (Core + SkiaSharp)

Missing XML comment for publicly visible type or member 'DrawnComboBox.OnLostFocus()'

Check warning on line 611 in src/KumikoUI.Core/Components/DrawnComboBox.cs

View workflow job for this annotation

GitHub Actions / Build & Test (Core + SkiaSharp)

Missing XML comment for publicly visible type or member 'DrawnComboBox.OnLostFocus()'

Check warning on line 611 in src/KumikoUI.Core/Components/DrawnComboBox.cs

View workflow job for this annotation

GitHub Actions / Build (KumikoUI.Maui — all TFMs)

Missing XML comment for publicly visible type or member 'DrawnComboBox.OnLostFocus()'

Check warning on line 611 in src/KumikoUI.Core/Components/DrawnComboBox.cs

View workflow job for this annotation

GitHub Actions / Build (KumikoUI.Maui — all TFMs)

Missing XML comment for publicly visible type or member 'DrawnComboBox.OnLostFocus()'
{
CloseDropdown();
base.OnLostFocus();
Expand Down
7 changes: 7 additions & 0 deletions src/KumikoUI.Core/DataGridRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using KumikoUI.Core.Components;
using KumikoUI.Core.Editing;
using KumikoUI.Core.Layout;
Expand All @@ -22,7 +23,7 @@
private readonly List<(DataGridColumn Column, float X, int OriginalIndex)> _scrollableCols = new();
private readonly List<(DataGridColumn Column, float X, int OriginalIndex)> _allCols = new();

public DataGridRenderer()

Check warning on line 26 in src/KumikoUI.Core/DataGridRenderer.cs

View workflow job for this annotation

GitHub Actions / Build & Test (Core + SkiaSharp)

Missing XML comment for publicly visible type or member 'DataGridRenderer.DataGridRenderer()'

Check warning on line 26 in src/KumikoUI.Core/DataGridRenderer.cs

View workflow job for this annotation

GitHub Actions / Build & Test (Core + SkiaSharp)

Missing XML comment for publicly visible type or member 'DataGridRenderer.DataGridRenderer()'

Check warning on line 26 in src/KumikoUI.Core/DataGridRenderer.cs

View workflow job for this annotation

GitHub Actions / Build (KumikoUI.Maui — all TFMs)

Missing XML comment for publicly visible type or member 'DataGridRenderer.DataGridRenderer()'

Check warning on line 26 in src/KumikoUI.Core/DataGridRenderer.cs

View workflow job for this annotation

GitHub Actions / Build (KumikoUI.Maui — all TFMs)

Missing XML comment for publicly visible type or member 'DataGridRenderer.DataGridRenderer()'
{
var textRenderer = new TextCellRenderer();
_cellRenderers[DataGridColumnType.Text] = textRenderer;
Expand Down Expand Up @@ -467,6 +468,8 @@
frozenWidth, editSession, topSummaryHeight, frozenRowCount);
}

[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "DataGridRenderer is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
private void DrawRows(
IDrawingContext ctx,
DataGridSource dataSource,
Expand Down Expand Up @@ -619,6 +622,8 @@
isFrozenCol ? ColumnFreezeMode.Left : ColumnFreezeMode.None, frozenWidth, editSession, topSummaryHeight);
}

[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "DataGridRenderer is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
private void DrawFrozenRows(
IDrawingContext ctx,
DataGridSource dataSource,
Expand Down Expand Up @@ -1648,6 +1653,8 @@
/// <summary>
/// Draws a ghost row at the drag position and an insertion indicator line at the drop target.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "DataGridRenderer is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
private void DrawRowDragOverlay(
IDrawingContext ctx,
DataGridSource dataSource,
Expand Down
3 changes: 3 additions & 0 deletions src/KumikoUI.Core/Editing/CellEditorFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using KumikoUI.Core.Components;
using KumikoUI.Core.Models;
using KumikoUI.Core.Rendering;
Expand Down Expand Up @@ -146,6 +147,8 @@ private static DrawnDatePicker CreateDateEditor(
return picker;
}

[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "CellEditorFactory is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
private static DrawnComboBox CreateComboBoxEditor(
DataGridColumn column, object? value, GridRect cellBounds)
{
Expand Down
7 changes: 7 additions & 0 deletions src/KumikoUI.Core/Editing/EditSession.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using KumikoUI.Core.Components;
using KumikoUI.Core.Input;
using KumikoUI.Core.Models;
Expand Down Expand Up @@ -242,6 +243,8 @@ public class EditSession
/// <summary>
/// Try to begin editing a cell.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "EditSession is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
public bool BeginEdit(
int rowIndex, int columnIndex,
DataGridColumn column, DataGridSource dataSource,
Expand Down Expand Up @@ -292,6 +295,8 @@ public bool BeginEdit(
/// Commit the current edit, writing the value back.
/// Returns true if the commit succeeded.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "EditSession is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
public bool CommitEdit(DataGridSource dataSource)
{
if (!_isEditing || _activeEditor == null || _editColumn == null) return false;
Expand Down Expand Up @@ -344,6 +349,8 @@ public void CancelEdit()
/// <summary>
/// Toggle a boolean cell directly (no editor needed).
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "EditSession is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
public void ToggleBooleanCell(int row, int col, DataGridColumn column, DataGridSource dataSource)
{
if (column.IsReadOnly) return;
Expand Down
1 change: 1 addition & 0 deletions src/KumikoUI.Core/KumikoUI.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsAotCompatible>true</IsAotCompatible>

<!-- NuGet Package Metadata (Authors, License, URLs inherited from Directory.Build.props) -->
<PackageId>KumikoUI.Core</PackageId>
Expand Down
3 changes: 3 additions & 0 deletions src/KumikoUI.Core/Layout/GridLayoutEngine.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace KumikoUI.Core.Layout;

using System.Diagnostics.CodeAnalysis;
using KumikoUI.Core.Models;
using KumikoUI.Core.Rendering;

Expand Down Expand Up @@ -316,6 +317,8 @@ public float GetColumnX(IReadOnlyList<DataGridColumn> columns, int columnIndex)
/// Measures the header text and all visible cell display text, then returns
/// the maximum width plus padding, clamped to MinWidth/MaxWidth.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "GridLayoutEngine is part of the library and operates on columns configured by the consuming app. Reflection use is intentional.")]
Comment on lines +320 to +321
public float CalculateAutoFitWidth(
DataGridColumn column,
DataGridSource dataSource,
Expand Down
24 changes: 24 additions & 0 deletions src/KumikoUI.Core/Models/DataGridColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ public class DataGridColumn
/// <summary>Property path on the data item (supports nested: "Address.City").</summary>
public string PropertyName { get; set; } = string.Empty;

/// <summary>
/// Optional AOT-safe delegate for reading a cell value from a data item.
/// When set, this is used instead of the reflection-based <see cref="PropertyName"/> accessor.
/// Prefer this over <see cref="PropertyName"/> in NativeAOT or trimmed applications.
/// <example>
/// <code>
/// new DataGridColumn { ValueAccessor = item => ((Person)item).FirstName }
/// </code>
/// </example>
/// </summary>
public Func<object, object?>? ValueAccessor { get; set; }

Comment on lines 82 to +96
/// <summary>
/// Optional AOT-safe delegate for writing a cell value back to a data item.
/// When set, this is used instead of the reflection-based <see cref="PropertyName"/> setter.
/// Prefer this over <see cref="PropertyName"/> in NativeAOT or trimmed applications.
/// <example>
/// <code>
/// new DataGridColumn { ValueSetter = (item, value) => ((Person)item).FirstName = (string?)value }
/// </code>
/// </example>
/// </summary>
public Action<object, object?>? ValueSetter { get; set; }

/// <summary>Type of column for rendering dispatch.</summary>
public DataGridColumnType ColumnType { get; set; } = DataGridColumnType.Text;

Expand Down
Loading
Loading