Skip to content

Commit 4dd0f3b

Browse files
Initial implementation of the ToolkitSampleButtonAttribute
This allows sample dev to easily add attribute to a method on their sample page in order to create a UI command button that executes that code (e.g. adding/removing items from a collection) Example: ```cs [ToolkitSampleButton(Title = "Add 5 Items")] private void Add5ItemsClick() { ``` Attribute takes a Title like others do for the label of the button. Doesn't support enabling/disabling of button with CanExecute, just execution for MVP. Creates a separate list of items in the ToolkitSampleMetadata to record the method names. Currently uses reflection to bind to the method at runtime, so we need to investigate if better way... Main challenge is taking method name from registry (even with class instance and such) and binding to the constructed instance of the page's methods... There may have been some complication/duplication here due to how our generators run in two different contexts...? Note: Assisted by GitHub Copilot using Claude Opus 4.6
1 parent cdf9aa9 commit 4dd0f3b

11 files changed

Lines changed: 550 additions & 16 deletions

CommunityToolkit.App.Shared/Renderers/ToolkitSampleRenderer.xaml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
<Page x:Class="CommunityToolkit.App.Shared.Renderers.ToolkitSampleRenderer"
33
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
44
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
5+
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
56
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
67
xmlns:local="using:CommunityToolkit.App.Shared"
78
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:metadata="using:CommunityToolkit.Tooling.SampleGen.Metadata"
810
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
911
xmlns:not_win="http://uno.ui/not_win"
1012
xmlns:renderer="using:CommunityToolkit.App.Shared.Renderers"
@@ -14,6 +16,10 @@
1416
Loaded="ToolkitSampleRenderer_Loaded"
1517
mc:Ignorable="d wasm not_win">
1618

19+
<Page.Resources>
20+
<converters:CollectionVisibilityConverter x:Key="CollectionVisibilityConverter" />
21+
</Page.Resources>
22+
1723
<Grid CornerRadius="0">
1824
<Grid.RowDefinitions>
1925
<RowDefinition Height="*" />
@@ -49,7 +55,7 @@
4955
<AdaptiveTrigger MinWindowWidth="0" />
5056
</VisualState.StateTriggers>
5157
<VisualState.Setters>
52-
<Setter Target="OptionsPanel.(Grid.Row)" Value="1" />
58+
<Setter Target="OptionsPanel.(Grid.Row)" Value="2" />
5359
<Setter Target="OptionsPanel.(Grid.Column)" Value="0" />
5460
<Setter Target="OptionsPanel.BorderThickness" Value="0,1,0,0" />
5561
<Setter Target="FixedOptionsBar.BorderThickness" Value="0" />
@@ -76,6 +82,7 @@
7682
BorderThickness="1,1,1,1"
7783
CornerRadius="8">
7884
<Grid.RowDefinitions>
85+
<RowDefinition Height="Auto" />
7986
<RowDefinition Height="*" />
8087
<RowDefinition Height="Auto" />
8188
<RowDefinition Height="Auto" />
@@ -85,7 +92,32 @@
8592
<ColumnDefinition Width="Auto" />
8693
</Grid.ColumnDefinitions>
8794

95+
<ScrollViewer x:Name="CommandsPanel"
96+
Grid.Row="0"
97+
Grid.Column="0"
98+
Padding="8"
99+
Visibility="{x:Bind Metadata.SampleButtons, Mode=OneWay, Converter={StaticResource CollectionVisibilityConverter}}">
100+
<ItemsControl x:Name="CommandsControl"
101+
ItemsSource="{x:Bind Metadata.SampleButtons, Mode=OneWay}">
102+
<ItemsControl.ItemTemplate>
103+
<DataTemplate x:DataType="metadata:ToolkitSampleButtonCommand">
104+
<Button Command="{x:Bind (metadata:ToolkitSampleButtonCommand), Mode=OneWay}"
105+
Content="{x:Bind Title, Mode=OneWay}" />
106+
</DataTemplate>
107+
</ItemsControl.ItemTemplate>
108+
<ItemsControl.ItemsPanel>
109+
<ItemsPanelTemplate>
110+
<StackPanel HorizontalAlignment="Left"
111+
VerticalAlignment="Center"
112+
Orientation="Horizontal"
113+
Spacing="8" />
114+
</ItemsPanelTemplate>
115+
</ItemsControl.ItemsPanel>
116+
</ItemsControl>
117+
</ScrollViewer>
118+
88119
<Grid x:Name="OptionsPanel"
120+
Grid.RowSpan="2"
89121
Grid.Column="1"
90122
VerticalAlignment="Stretch"
91123
Background="{ThemeResource LayerFillColorDefaultBrush}"
@@ -178,6 +210,7 @@
178210
</Grid>
179211

180212
<Grid x:Name="ContentPageHolder"
213+
Grid.Row="1"
181214
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}">
182215
<!-- A solidbackground we enable when toggling themes. WinUI uses a lot of translucent brushes and might look weird -->
183216
<Border x:Name="ThemeBG"
@@ -196,7 +229,7 @@
196229
</Grid>
197230

198231
<muxc:Expander x:Name="SourcecodeExpander"
199-
Grid.Row="2"
232+
Grid.Row="3"
200233
Grid.ColumnSpan="3"
201234
MinHeight="0"
202235
Margin="0,-1,0,0"

CommunityToolkit.App.Shared/Renderers/ToolkitSampleRenderer.xaml.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ private async Task LoadData()
181181

182182
var sampleControlInstance = (UIElement)Metadata.SampleControlFactory();
183183

184+
// Bind button commands to the sample instance so they can invoke methods via reflection.
185+
if (Metadata.SampleButtons is not null)
186+
{
187+
foreach (var button in Metadata.SampleButtons)
188+
{
189+
button.BindToInstance(sampleControlInstance);
190+
}
191+
}
192+
184193
// Custom control-based sample options.
185194
if (Metadata.SampleOptionsPaneType is not null && Metadata.SampleOptionsPaneFactory is not null)
186195
{

CommunityToolkit.Tooling.SampleGen.Tests/ToolkitSampleGeneratedPaneTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public static class ToolkitSampleRegistry
101101
{
102102
public static System.Collections.Generic.Dictionary<string, CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleMetadata> Listing
103103
{ get; } = new() {
104-
["Sample"] = new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleMetadata("Sample", "Test Sample", "", typeof(MyApp.Sample), () => new MyApp.Sample(), null, null, new CommunityToolkit.Tooling.SampleGen.Metadata.IGeneratedToolkitSampleOptionViewModel[] { new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleNumericOptionMetadataViewModel(name: "TextSize", initial: 12, min: 8, max: 48, step: 2, showAsNumberBox: false, title: "FontSize") })
104+
["Sample"] = new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleMetadata("Sample", "Test Sample", "", typeof(MyApp.Sample), () => new MyApp.Sample(), null, null, new CommunityToolkit.Tooling.SampleGen.Metadata.IGeneratedToolkitSampleOptionViewModel[] { new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleNumericOptionMetadataViewModel(name: "TextSize", initial: 12, min: 8, max: 48, step: 2, showAsNumberBox: false, title: "FontSize") }, null)
105105
};
106106
}
107107
""", "Unexpected code generated");
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Tooling.SampleGen.Diagnostics;
6+
using CommunityToolkit.Tooling.SampleGen.Tests.Helpers;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
using System.Collections.Immutable;
9+
using System.ComponentModel.DataAnnotations;
10+
11+
namespace CommunityToolkit.Tooling.SampleGen.Tests;
12+
13+
public partial class ToolkitSampleMetadataTests
14+
{
15+
[TestMethod]
16+
public void SampleButtonAttributeOnNonSample()
17+
{
18+
var source = """
19+
using System.ComponentModel;
20+
using CommunityToolkit.Tooling.SampleGen;
21+
using CommunityToolkit.Tooling.SampleGen.Attributes;
22+
23+
namespace MyApp
24+
{
25+
public partial class Sample : Windows.UI.Xaml.Controls.UserControl
26+
{
27+
[ToolkitSampleButton(Title = "Click Me")]
28+
private void OnButtonClick()
29+
{
30+
}
31+
}
32+
}
33+
34+
namespace Windows.UI.Xaml.Controls
35+
{
36+
public class UserControl { }
37+
}
38+
""";
39+
40+
var result = source.RunSourceGenerator<ToolkitSampleMetadataGenerator>(SAMPLE_ASM_NAME);
41+
42+
result.AssertDiagnosticsAre(DiagnosticDescriptors.SampleButtonAttributeOnNonSample);
43+
result.AssertNoCompilationErrors();
44+
}
45+
46+
[TestMethod]
47+
public void SampleButtonAttributeValid()
48+
{
49+
var source = """
50+
using System.ComponentModel;
51+
using CommunityToolkit.Tooling.SampleGen;
52+
using CommunityToolkit.Tooling.SampleGen.Attributes;
53+
54+
namespace MyApp
55+
{
56+
[ToolkitSample(id: nameof(Sample), "Test Sample", description: "")]
57+
public partial class Sample : Windows.UI.Xaml.Controls.UserControl
58+
{
59+
[ToolkitSampleButton(Title = "Click Me")]
60+
private void OnButtonClick()
61+
{
62+
}
63+
}
64+
}
65+
66+
namespace Windows.UI.Xaml.Controls
67+
{
68+
public class UserControl { }
69+
}
70+
""";
71+
72+
var result = source.RunSourceGenerator<ToolkitSampleMetadataGenerator>(SAMPLE_ASM_NAME);
73+
74+
result.AssertDiagnosticsAre(DiagnosticDescriptors.SampleNotReferencedInMarkdown);
75+
result.AssertNoCompilationErrors();
76+
}
77+
78+
[TestMethod]
79+
public void SampleButtonAttributeMultipleButtons()
80+
{
81+
var source = """
82+
using System.ComponentModel;
83+
using CommunityToolkit.Tooling.SampleGen;
84+
using CommunityToolkit.Tooling.SampleGen.Attributes;
85+
86+
namespace MyApp
87+
{
88+
[ToolkitSample(id: nameof(Sample), "Test Sample", description: "")]
89+
public partial class Sample : Windows.UI.Xaml.Controls.UserControl
90+
{
91+
[ToolkitSampleButton(Title = "Add Item")]
92+
private void AddItemClick()
93+
{
94+
}
95+
96+
[ToolkitSampleButton(Title = "Clear Items")]
97+
private void ClearItemsClick()
98+
{
99+
}
100+
}
101+
}
102+
103+
namespace Windows.UI.Xaml.Controls
104+
{
105+
public class UserControl { }
106+
}
107+
""";
108+
109+
var result = source.RunSourceGenerator<ToolkitSampleMetadataGenerator>(SAMPLE_ASM_NAME);
110+
111+
result.AssertDiagnosticsAre(DiagnosticDescriptors.SampleNotReferencedInMarkdown);
112+
result.AssertNoCompilationErrors();
113+
}
114+
115+
[TestMethod]
116+
public void SampleButtonCommand_GeneratedRegistryExecutesMethod()
117+
{
118+
// The sample registry is designed to be declared in the sample project,
119+
// and generated in the project head. To test end-to-end execution of the
120+
// generated button commands, we replicate this setup, verify the generated
121+
// registry source, then execute the same command against a matching instance.
122+
var sampleSource = """
123+
using System.ComponentModel;
124+
using CommunityToolkit.Tooling.SampleGen;
125+
using CommunityToolkit.Tooling.SampleGen.Attributes;
126+
127+
namespace MyApp
128+
{
129+
[ToolkitSample(id: nameof(Sample), "Test Sample", description: "")]
130+
public partial class Sample : Windows.UI.Xaml.Controls.UserControl
131+
{
132+
public int Counter { get; set; }
133+
134+
public Sample()
135+
{
136+
}
137+
138+
[ToolkitSampleButton(Title = "Increment")]
139+
private void IncrementCounter()
140+
{
141+
Counter++;
142+
}
143+
}
144+
}
145+
146+
namespace Windows.UI.Xaml.Controls
147+
{
148+
public class UserControl { }
149+
}
150+
""";
151+
152+
// Compile sample project as a metadata reference for the generator
153+
var sampleProjectAssembly = sampleSource.ToSyntaxTree()
154+
.CreateCompilation("MyApp.Samples")
155+
.ToMetadataReference();
156+
157+
// Create application head that references the sample project
158+
var headCompilation = string.Empty
159+
.ToSyntaxTree()
160+
.CreateCompilation("MyApp.Head")
161+
.AddReferences(sampleProjectAssembly);
162+
163+
// Run source generator to produce the registry
164+
var result = headCompilation.RunSourceGenerator<ToolkitSampleMetadataGenerator>();
165+
166+
result.AssertDiagnosticsAre();
167+
result.AssertNoCompilationErrors();
168+
169+
// Verify the generated registry contains the expected button command
170+
var registrySource = result.Compilation.GetFileContentsByName("ToolkitSampleRegistry.g.cs");
171+
StringAssert.Contains(registrySource, @"new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleButtonCommand(""Increment"", ""IncrementCounter"")");
172+
173+
// Now verify the generated command mechanism works end-to-end
174+
// by creating the same command the registry would and binding it to an instance
175+
var testInstance = new SampleButtonCommandTestTarget();
176+
Assert.AreEqual(0, testInstance.Counter);
177+
178+
var button = new Metadata.ToolkitSampleButtonCommand("Increment", "IncrementCounter");
179+
button.BindToInstance(testInstance);
180+
181+
Assert.AreEqual("Increment", button.Title);
182+
Assert.AreEqual("IncrementCounter", button.MethodName);
183+
Assert.IsTrue(button.CanExecute(null!));
184+
185+
// Execute and verify the counter was incremented
186+
button.Execute(null!);
187+
Assert.AreEqual(1, testInstance.Counter);
188+
189+
button.Execute(null!);
190+
Assert.AreEqual(2, testInstance.Counter);
191+
}
192+
193+
[TestMethod]
194+
public void SampleButtonCommand_AssemblyAttributeBridge()
195+
{
196+
// Verifies that the sample project emits assembly-level ToolkitSampleButtonDataAttribute
197+
// and that the head project can read them to populate button commands in the registry.
198+
// This tests the mechanism that bridges private method visibility across PE references.
199+
var sampleSource = """
200+
using System.ComponentModel;
201+
using CommunityToolkit.Tooling.SampleGen;
202+
using CommunityToolkit.Tooling.SampleGen.Attributes;
203+
204+
namespace MyApp
205+
{
206+
[ToolkitSample(id: nameof(Sample), "Test Sample", description: "")]
207+
public partial class Sample : Windows.UI.Xaml.Controls.UserControl
208+
{
209+
[ToolkitSampleButton(Title = "Increment")]
210+
private void IncrementCounter()
211+
{
212+
}
213+
}
214+
}
215+
216+
namespace Windows.UI.Xaml.Controls
217+
{
218+
public class UserControl { }
219+
}
220+
""";
221+
222+
// Step 1: Run the generator on the sample project to produce assembly-level button attributes
223+
var sampleResult = sampleSource.RunSourceGenerator<ToolkitSampleMetadataGenerator>(SAMPLE_ASM_NAME);
224+
sampleResult.AssertNoCompilationErrors();
225+
226+
// Verify the sample project generated the assembly-level button metadata
227+
var buttonMetadataSource = sampleResult.Compilation.GetFileContentsByName("ToolkitSampleButtonMetadata.g.cs");
228+
StringAssert.Contains(buttonMetadataSource, @"ToolkitSampleButtonDataAttribute(""MyApp.Sample"", ""IncrementCounter"", ""Increment"")");
229+
230+
// Step 2: Compile the sample project WITH the generated source and create a reference
231+
var sampleWithGenerated = sampleResult.Compilation.ToMetadataReference();
232+
233+
// Step 3: Run the generator on the head project
234+
var headCompilation = string.Empty
235+
.ToSyntaxTree()
236+
.CreateCompilation("MyApp.Head")
237+
.AddReferences(sampleWithGenerated);
238+
239+
var headResult = headCompilation.RunSourceGenerator<ToolkitSampleMetadataGenerator>();
240+
241+
headResult.AssertDiagnosticsAre();
242+
headResult.AssertNoCompilationErrors();
243+
244+
// Verify the head project's registry includes the button command
245+
var registrySource = headResult.Compilation.GetFileContentsByName("ToolkitSampleRegistry.g.cs");
246+
StringAssert.Contains(registrySource, @"new CommunityToolkit.Tooling.SampleGen.Metadata.ToolkitSampleButtonCommand(""Increment"", ""IncrementCounter"")");
247+
}
248+
}
249+
250+
/// <summary>
251+
/// Mirrors the generated sample class structure for testing <see cref="Metadata.ToolkitSampleButtonCommand"/> execution.
252+
/// </summary>
253+
internal class SampleButtonCommandTestTarget
254+
{
255+
public int Counter { get; set; }
256+
257+
private void IncrementCounter() => Counter++;
258+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.Tooling.SampleGen.Attributes;
6+
7+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
8+
public class ToolkitSampleButtonAttribute : Attribute
9+
{
10+
/// <summary>
11+
/// The title to display on the button.
12+
/// </summary>
13+
public string? Title { get; set; }
14+
15+
/// <summary>
16+
/// The name of the method this attribute is attached to.
17+
/// Set during source generation.
18+
/// </summary>
19+
public string? MethodName { get; set; }
20+
}

0 commit comments

Comments
 (0)