From 5539e2c9415ada27230e441cc02b18cf05af0e7d Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 10:54:12 +0200 Subject: [PATCH 01/11] feat(maui): ShellLabel text alignment and line break mode HorizontalTextAlignment and LineBreakMode pass through to the inner Label, so centred or truncated text no longer needs a plain Label. Avalonia's ShellLabel is a TextBlock and already has TextAlignment and TextWrapping. Closes #27. --- examples/MAUI.Demo/Components/UI/Label.cs | 21 +++++++++++++++++++ .../Templates/LabelTemplate.cs | 21 +++++++++++++++++++ .../TemplateContentTests.cs | 11 ++++++++++ 3 files changed, 53 insertions(+) diff --git a/examples/MAUI.Demo/Components/UI/Label.cs b/examples/MAUI.Demo/Components/UI/Label.cs index 50bb5fd..b2f309f 100644 --- a/examples/MAUI.Demo/Components/UI/Label.cs +++ b/examples/MAUI.Demo/Components/UI/Label.cs @@ -19,6 +19,14 @@ public partial class ShellLabel : ContentView BindableProperty.Create(nameof(Variant), typeof(LabelVariant), typeof(ShellLabel), LabelVariant.Default, propertyChanged: OnVisualPropertyChanged); + public static readonly BindableProperty HorizontalTextAlignmentProperty = + BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(ShellLabel), + TextAlignment.Start, propertyChanged: (b, o, n) => ((ShellLabel)b)._label.HorizontalTextAlignment = (TextAlignment)n); + + public static readonly BindableProperty LineBreakModeProperty = + BindableProperty.Create(nameof(LineBreakMode), typeof(LineBreakMode), typeof(ShellLabel), + LineBreakMode.WordWrap, propertyChanged: (b, o, n) => ((ShellLabel)b)._label.LineBreakMode = (LineBreakMode)n); + private readonly Label _label; public string Text @@ -45,6 +53,19 @@ public LabelVariant Variant set => SetValue(VariantProperty, value); } + public TextAlignment HorizontalTextAlignment + { + get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + // WordWrap by default, like MAUI's Label; TailTruncation keeps one line with an ellipsis. + public LineBreakMode LineBreakMode + { + get => (LineBreakMode)GetValue(LineBreakModeProperty); + set => SetValue(LineBreakModeProperty, value); + } + public ShellLabel() { _label = new Label(); diff --git a/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs b/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs index 6c59485..77d8a17 100644 --- a/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs @@ -40,6 +40,14 @@ public partial class ShellLabel : ContentView BindableProperty.Create(nameof(Variant), typeof(LabelVariant), typeof(ShellLabel), LabelVariant.Default, propertyChanged: OnVisualPropertyChanged); + public static readonly BindableProperty HorizontalTextAlignmentProperty = + BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(ShellLabel), + TextAlignment.Start, propertyChanged: (b, o, n) => ((ShellLabel)b)._label.HorizontalTextAlignment = (TextAlignment)n); + + public static readonly BindableProperty LineBreakModeProperty = + BindableProperty.Create(nameof(LineBreakMode), typeof(LineBreakMode), typeof(ShellLabel), + LineBreakMode.WordWrap, propertyChanged: (b, o, n) => ((ShellLabel)b)._label.LineBreakMode = (LineBreakMode)n); + private readonly Label _label; public string Text @@ -66,6 +74,19 @@ public LabelVariant Variant set => SetValue(VariantProperty, value); } + public TextAlignment HorizontalTextAlignment + { + get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + // WordWrap by default, like MAUI's Label; TailTruncation keeps one line with an ellipsis. + public LineBreakMode LineBreakMode + { + get => (LineBreakMode)GetValue(LineBreakModeProperty); + set => SetValue(LineBreakModeProperty, value); + } + public ShellLabel() { _label = new Label(); diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index cad5840..2c8aa7d 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -231,4 +231,15 @@ public void Every_component_reports_MAUI_as_a_supported_platform(string name) $"Component '{name}' does not report MAUI as a supported platform. " + $"GetSupportedPlatforms returned: [{string.Join(", ", ComponentRegistry.GetSupportedPlatforms(name))}]"); } + + // ShellLabel passes text alignment and wrapping through (issue #27). Avalonia's ShellLabel is a + // TextBlock, so TextAlignment and TextWrapping are already its own properties. + [Fact] + public void MAUI_label_exposes_text_alignment_and_line_break_mode() + { + var content = ComponentRegistry.GetComponentContent("label", NativePlatform.MAUI)!; + Assert.Contains("HorizontalTextAlignmentProperty", content); + Assert.Contains("LineBreakModeProperty", content); + Assert.Contains("class ShellLabel : TextBlock", ComponentRegistry.GetComponentContent("label", NativePlatform.Avalonia)!); + } } From c3e2ad065a1e598e7723a004a6a61b610ced47dc Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 10:54:13 +0200 Subject: [PATCH 02/11] feat: sheet MaxPanelWidth on MAUI and Avalonia SheetContent.MaxPanelWidth (default 384, shadcn's sm:max-w-sm) sets how wide a left or right sheet gets; it still never covers more than 75% of the window. Closes #26. --- .../Components/UI/SheetContent.cs | 17 ++++++++++- .../MAUI.Demo/Components/UI/SheetContent.cs | 13 +++++++- .../Templates/SheetContentTemplate.cs | 30 +++++++++++++++++-- .../TemplateContentTests.cs | 11 +++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/SheetContent.cs b/examples/Avalonia.Demo/Components/UI/SheetContent.cs index 7081a32..498758e 100644 --- a/examples/Avalonia.Demo/Components/UI/SheetContent.cs +++ b/examples/Avalonia.Demo/Components/UI/SheetContent.cs @@ -15,6 +15,21 @@ namespace AvaloniaDemo.Components.UI; // close button top-right, slides in from its side over a dimmed backdrop. XAML children go to Items. public class SheetContent : Border, IShellOverlayContent { + // Widest a left or right sheet gets (384 is sm:max-w-sm); it never covers more than 75% of the window. + public static readonly StyledProperty MaxPanelWidthProperty = + AvaloniaProperty.Register(nameof(MaxPanelWidth), 384); + + static SheetContent() + { + MaxPanelWidthProperty.Changed.AddClassHandler((c, _) => { if (c.Bounds.Width > 0) c.ApplySide(); }); + } + + public double MaxPanelWidth + { + get => GetValue(MaxPanelWidthProperty); + set => SetValue(MaxPanelWidthProperty, value); + } + private readonly Border _backdrop; private readonly Border _panel; private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(0, 0, 24, 0) }; @@ -93,7 +108,7 @@ private void ApplySide() SheetSide.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Stretch }; - _panel.Width = vertical ? Math.Min(384, Bounds.Width * 0.75) : double.NaN; + _panel.Width = vertical ? Math.Min(MaxPanelWidth, Bounds.Width * 0.75) : double.NaN; } // translate() that puts the panel just past its edge; invariant, so it parses in any culture. diff --git a/examples/MAUI.Demo/Components/UI/SheetContent.cs b/examples/MAUI.Demo/Components/UI/SheetContent.cs index 6372e08..8ee7f9e 100644 --- a/examples/MAUI.Demo/Components/UI/SheetContent.cs +++ b/examples/MAUI.Demo/Components/UI/SheetContent.cs @@ -7,6 +7,17 @@ namespace MAUI.Demo.Components.UI; [ContentProperty(nameof(Children))] public partial class SheetContent : ContentView, IShellOverlayContent { + // Widest a left or right sheet gets (384 is sm:max-w-sm); it never covers more than 75% of the window. + public static readonly BindableProperty MaxPanelWidthProperty = + BindableProperty.Create(nameof(MaxPanelWidth), typeof(double), typeof(SheetContent), 384d, + propertyChanged: (b, o, n) => { if (((SheetContent)b)._root.Width > 0) ((SheetContent)b).ApplySide(); }); + + public double MaxPanelWidth + { + get => (double)GetValue(MaxPanelWidthProperty); + set => SetValue(MaxPanelWidthProperty, value); + } + private readonly BoxView _backdrop; private readonly Border _panel; private readonly VerticalStackLayout _body; @@ -83,7 +94,7 @@ private void ApplySide() SheetSide.Right => LayoutOptions.End, _ => LayoutOptions.Fill }; - _panel.WidthRequest = vertical ? Math.Min(384, _root.Width * 0.75) : -1; + _panel.WidthRequest = vertical ? Math.Min(MaxPanelWidth, _root.Width * 0.75) : -1; // The panel runs under the system bars; its content doesn't. var safe = ShellPortal.GetSafeInsets(_root); _panel.Padding = new Thickness( diff --git a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs index bc1757a..9700b9d 100644 --- a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs @@ -26,6 +26,17 @@ namespace YourProjectNamespace.Components.UI; [ContentProperty(nameof(Children))] public partial class SheetContent : ContentView, IShellOverlayContent { + // Widest a left or right sheet gets (384 is sm:max-w-sm); it never covers more than 75% of the window. + public static readonly BindableProperty MaxPanelWidthProperty = + BindableProperty.Create(nameof(MaxPanelWidth), typeof(double), typeof(SheetContent), 384d, + propertyChanged: (b, o, n) => { if (((SheetContent)b)._root.Width > 0) ((SheetContent)b).ApplySide(); }); + + public double MaxPanelWidth + { + get => (double)GetValue(MaxPanelWidthProperty); + set => SetValue(MaxPanelWidthProperty, value); + } + private readonly BoxView _backdrop; private readonly Border _panel; private readonly VerticalStackLayout _body; @@ -102,7 +113,7 @@ private void ApplySide() SheetSide.Right => LayoutOptions.End, _ => LayoutOptions.Fill }; - _panel.WidthRequest = vertical ? Math.Min(384, _root.Width * 0.75) : -1; + _panel.WidthRequest = vertical ? Math.Min(MaxPanelWidth, _root.Width * 0.75) : -1; // The panel runs under the system bars; its content doesn't. var safe = ShellPortal.GetSafeInsets(_root); _panel.Padding = new Thickness( @@ -163,6 +174,21 @@ namespace YourProjectNamespace.Components.UI; // close button top-right, slides in from its side over a dimmed backdrop. XAML children go to Items. public class SheetContent : Border, IShellOverlayContent { + // Widest a left or right sheet gets (384 is sm:max-w-sm); it never covers more than 75% of the window. + public static readonly StyledProperty MaxPanelWidthProperty = + AvaloniaProperty.Register(nameof(MaxPanelWidth), 384); + + static SheetContent() + { + MaxPanelWidthProperty.Changed.AddClassHandler((c, _) => { if (c.Bounds.Width > 0) c.ApplySide(); }); + } + + public double MaxPanelWidth + { + get => GetValue(MaxPanelWidthProperty); + set => SetValue(MaxPanelWidthProperty, value); + } + private readonly Border _backdrop; private readonly Border _panel; private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(0, 0, 24, 0) }; @@ -241,7 +267,7 @@ private void ApplySide() SheetSide.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Stretch }; - _panel.Width = vertical ? Math.Min(384, Bounds.Width * 0.75) : double.NaN; + _panel.Width = vertical ? Math.Min(MaxPanelWidth, Bounds.Width * 0.75) : double.NaN; } // translate() that puts the panel just past its edge; invariant, so it parses in any culture. diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 2c8aa7d..d5baab0 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -242,4 +242,15 @@ public void MAUI_label_exposes_text_alignment_and_line_break_mode() Assert.Contains("LineBreakModeProperty", content); Assert.Contains("class ShellLabel : TextBlock", ComponentRegistry.GetComponentContent("label", NativePlatform.Avalonia)!); } + + // Sheets can be wider than shadcn's 384px (issue #26). + [Theory] + [InlineData(NativePlatform.MAUI)] + [InlineData(NativePlatform.Avalonia)] + public void Sheet_content_has_a_max_panel_width(NativePlatform platform) + { + var content = ComponentRegistry.GetComponentContent("sheet-content", platform)!; + Assert.Contains("MaxPanelWidthProperty", content); + Assert.Contains("Math.Min(MaxPanelWidth", content); + } } From 6940f6ffb07f601857d5af4e949053182a4deb19 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 10:54:14 +0200 Subject: [PATCH 03/11] fix(maui): shell builds in a Blazor Hybrid app A MAUI Blazor Hybrid app has a .Components.Layout namespace for MainLayout.razor, and a bare Layout inside .Components.* resolved to it (CS0118). Shell.cs names Microsoft.Maui.Controls.Layout in full, and a test keeps bare Layout out of the MAUI templates. --- examples/MAUI.Demo/Components/UI/Shell.cs | 5 +++-- .../Templates/ShellTemplate.cs | 5 +++-- tests/ShellUI.Native.Tests/TemplateContentTests.cs | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index dbc303c..e7136d0 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -488,7 +488,8 @@ public static bool Attach(Element owner, View view) public static void Detach(View view) { - if (view.Parent is Layout layout) layout.Children.Remove(view); + // Qualified: Blazor Hybrid apps have a .Components.Layout namespace, which a bare Layout resolves to. + if (view.Parent is Microsoft.Maui.Controls.Layout layout) layout.Children.Remove(view); else if (view.Parent is ContentView host && host.Content == view) host.Content = null; } @@ -500,7 +501,7 @@ public static void EdgeToEdge(params View[] views) { switch (view) { - case Layout layout: layout.SafeAreaEdges = SafeAreaEdges.None; break; + case Microsoft.Maui.Controls.Layout layout: layout.SafeAreaEdges = SafeAreaEdges.None; break; case ScrollView scroll: scroll.SafeAreaEdges = SafeAreaEdges.None; break; case Border border: border.SafeAreaEdges = SafeAreaEdges.None; break; case ContentView content: content.SafeAreaEdges = SafeAreaEdges.None; break; diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 8f10ab6..04ee006 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -509,7 +509,8 @@ public static bool Attach(Element owner, View view) public static void Detach(View view) { - if (view.Parent is Layout layout) layout.Children.Remove(view); + // Qualified: Blazor Hybrid apps have a .Components.Layout namespace, which a bare Layout resolves to. + if (view.Parent is Microsoft.Maui.Controls.Layout layout) layout.Children.Remove(view); else if (view.Parent is ContentView host && host.Content == view) host.Content = null; } @@ -521,7 +522,7 @@ public static void EdgeToEdge(params View[] views) { switch (view) { - case Layout layout: layout.SafeAreaEdges = SafeAreaEdges.None; break; + case Microsoft.Maui.Controls.Layout layout: layout.SafeAreaEdges = SafeAreaEdges.None; break; case ScrollView scroll: scroll.SafeAreaEdges = SafeAreaEdges.None; break; case Border border: border.SafeAreaEdges = SafeAreaEdges.None; break; case ContentView content: content.SafeAreaEdges = SafeAreaEdges.None; break; diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index d5baab0..6c76ec2 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -253,4 +253,14 @@ public void Sheet_content_has_a_max_panel_width(NativePlatform platform) Assert.Contains("MaxPanelWidthProperty", content); Assert.Contains("Math.Min(MaxPanelWidth", content); } + + // A MAUI Blazor Hybrid app has a .Components.Layout namespace (MainLayout.razor), and a + // bare `Layout` inside .Components.* resolves to it (CS0118), so MAUI code names the type in full. + [Theory] + [MemberData(nameof(AllRegisteredComponents))] + public void MAUI_templates_name_the_Layout_type_in_full(string name) + { + var content = ComponentRegistry.GetComponentContent(name, NativePlatform.MAUI)!; + Assert.DoesNotMatch(@"(\b(is|as|case|new)\s+|typeof\(|[<(,]\s*)Layout\b(?![.\w])", content); + } } From 0abf5a8a8709a2eef2b334d88789a413b78d98d1 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 10:54:19 +0200 Subject: [PATCH 04/11] fix(cli): native components get their own folder beside ShellUI Blazor When init finds shellui.json, components go to Components/Native, and the namespace now follows the components folder (Components/UI stays .Components.UI, so existing projects get the same code). Before, both libraries wrote .Components.UI.Button and a hybrid app didn't build. The Blazor Hybrid guide covers the order of the two inits and has examples that build. Closes #28. --- docs/BLAZOR_HYBRID.md | 32 +++++++++++--- .../Services/ComponentInstaller.cs | 2 +- .../Services/ComponentManager.cs | 2 +- .../Services/InitService.cs | 25 +++++++---- .../Services/TemplateText.cs | 22 ++++++++++ .../ShellUI.Native.Tests/TemplateTextTests.cs | 44 +++++++++++++++++++ 6 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 src/ShellUI.Native.CLI/Services/TemplateText.cs create mode 100644 tests/ShellUI.Native.Tests/TemplateTextTests.cs diff --git a/docs/BLAZOR_HYBRID.md b/docs/BLAZOR_HYBRID.md index e1eb5e7..75b5b1b 100644 --- a/docs/BLAZOR_HYBRID.md +++ b/docs/BLAZOR_HYBRID.md @@ -70,6 +70,16 @@ shellui-native init --yes shellui-native add button dialog ``` +Run `shellui init` first. Both libraries have a `Button` (and many other components with the same +names), so they can't share a folder and namespace: when `shellui-native init` finds `shellui.json`, +it puts the native components in `Components/Native/`, namespace `YourApp.Components.Native`, and +leaves `Components/UI/` to ShellUI Blazor. + +If ShellUI Native was set up first, run `shellui-native init --force` after `shellui init`, reinstall +your native components with `shellui-native add --force`, and delete the old native `.cs` +files from `Components/UI/`. The folder is the `ComponentsPath` setting in `shellui-native.json`; +the namespace follows it. + ## File Structure With both libraries installed: @@ -77,11 +87,15 @@ With both libraries installed: ``` YourHybridApp/ ├── Components/ -│ ├── UI/ # ShellUI Native (MAUI) +│ ├── UI/ # ShellUI Blazor (YourApp.Components.UI) +│ │ ├── Button.razor +│ │ └── ... +│ ├── Native/ # ShellUI Native (YourApp.Components.Native) │ │ ├── Button.cs │ │ ├── Variants/ │ │ │ └── ButtonVariants.cs │ │ └── Shell.cs +│ ├── Layout/ # Your Blazor layout (MainLayout.razor) │ └── Pages/ # Your Blazor pages │ └── Home.razor ├── wwwroot/ @@ -96,15 +110,19 @@ YourHybridApp/ ### MainPage.xaml (Native MAUI) ```xml - - + + - + - + @@ -117,7 +135,7 @@ YourHybridApp/
- + @@ -136,7 +154,7 @@ YourHybridApp/ 2. **Add Native as Needed** - Only add ShellUI Native when you need truly native controls. -3. **Keep Separate** - The two libraries have different file paths (`Components/UI/` vs Blazor components) and configs (`shellui-native.json` vs `shellui.json`). +3. **Keep Separate** - The two libraries have their own folders and namespaces (`Components/UI/` for Blazor, `Components/Native/` for native) and configs (`shellui.json` vs `shellui-native.json`). 4. **Consistent Design** - Both libraries share the same design tokens, so your app will look consistent even when mixing native and web components. diff --git a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs index ad21826..17bd1b7 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs @@ -176,7 +176,7 @@ private static async Task InstallComponentInternalAsync( } // Replace namespace placeholder - content = content.Replace("YourProjectNamespace", projectInfo.RootNamespace); + content = TemplateText.Apply(content, projectInfo.RootNamespace, config.ComponentsPath); // Ensure directory exists var directory = Path.GetDirectoryName(componentPath); diff --git a/src/ShellUI.Native.CLI/Services/ComponentManager.cs b/src/ShellUI.Native.CLI/Services/ComponentManager.cs index b72deb2..b23f547 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentManager.cs @@ -185,7 +185,7 @@ public static void UpdateComponents(string[] components, bool all) var content = ComponentRegistry.GetComponentContent(componentName, config.TargetPlatform); if (content == null) continue; - content = content.Replace("YourProjectNamespace", projectInfo.RootNamespace); + content = TemplateText.Apply(content, projectInfo.RootNamespace, config.ComponentsPath); var componentPath = Path.Combine(Directory.GetCurrentDirectory(), config.ComponentsPath, metadata.FilePath); File.WriteAllText(componentPath, content); diff --git a/src/ShellUI.Native.CLI/Services/InitService.cs b/src/ShellUI.Native.CLI/Services/InitService.cs index e978ddc..70c0d77 100644 --- a/src/ShellUI.Native.CLI/Services/InitService.cs +++ b/src/ShellUI.Native.CLI/Services/InitService.cs @@ -55,26 +55,33 @@ public static async Task InitializeAsync(string style, bool force, bool nonInter AnsiConsole.MarkupLine("[yellow]Platform not detected, defaulting to MAUI[/]"); } + // ShellUI (Blazor) already uses Components/UI and .Components.UI; its Button.razor and our + // Button.cs would be the same class. + var besideBlazor = File.Exists(Path.Combine(Directory.GetCurrentDirectory(), "shellui.json")); + var componentsFolder = besideBlazor ? TemplateText.BesideBlazorComponentsPath : TemplateText.DefaultComponentsPath; + var componentsNamespace = TemplateText.ComponentsNamespace(projectInfo.RootNamespace, componentsFolder); + if (besideBlazor) + AnsiConsole.MarkupLine($"[green]✓ Found ShellUI (Blazor):[/] native components go to {componentsFolder}/ ({Markup.Escape(componentsNamespace)}) so the two sets don't clash"); + await LogoLoader.RunAsync("Setting up ShellUI Native...", async status => { - // Create Components/UI folder status("Creating component folders..."); - var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "Components", "UI"); + var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), componentsFolder); var variantsPath = Path.Combine(componentsPath, "Variants"); Directory.CreateDirectory(componentsPath); Directory.CreateDirectory(variantsPath); - AnsiConsole.MarkupLine($"[green]✓ Created:[/] Components/UI/"); + AnsiConsole.MarkupLine($"[green]✓ Created:[/] {componentsFolder}/"); // Install Shell utilities status("Installing Shell utilities..."); - await InstallShellUtilityAsync(projectInfo, componentsPath); + await InstallShellUtilityAsync(projectInfo, componentsPath, componentsFolder); // Create configuration file status("Creating configuration..."); var config = new ShellUINativeConfig { Style = style, - ComponentsPath = "Components/UI", + ComponentsPath = componentsFolder, TargetPlatform = projectInfo.Platform, Theme = new ThemeConfig { @@ -103,8 +110,8 @@ await LogoLoader.RunAsync("Setting up ShellUI Native...", async status => AnsiConsole.MarkupLine("\n[blue]Next steps:[/]"); if (projectInfo.Platform == NativePlatform.Avalonia) { - AnsiConsole.MarkupLine(" [dim]1. Load the theme in App.Initialize, after AvaloniaXamlLoader.Load(this):[/] Components.UI.ShellTheme.EnsureInitialized();"); - AnsiConsole.MarkupLine($" [dim]2. Use the components in XAML:[/] xmlns:ui=\"using:{Markup.Escape(projectInfo.RootNamespace)}.Components.UI\""); + AnsiConsole.MarkupLine($" [dim]1. Load the theme in App.Initialize, after AvaloniaXamlLoader.Load(this):[/] {Markup.Escape(componentsNamespace)}.ShellTheme.EnsureInitialized();"); + AnsiConsole.MarkupLine($" [dim]2. Use the components in XAML:[/] xmlns:ui=\"using:{Markup.Escape(componentsNamespace)}\""); AnsiConsole.MarkupLine(" [dim]3. Add components:[/] shellui-native add theme-toggle"); } else @@ -114,7 +121,7 @@ await LogoLoader.RunAsync("Setting up ShellUI Native...", async status => } } - private static async Task InstallShellUtilityAsync(ProjectInfo projectInfo, string componentsPath) + private static async Task InstallShellUtilityAsync(ProjectInfo projectInfo, string componentsPath, string componentsFolder) { if (!ComponentRegistry.SupportsPlatform("shell", projectInfo.Platform)) { @@ -127,7 +134,7 @@ private static async Task InstallShellUtilityAsync(ProjectInfo projectInfo, stri var content = ComponentRegistry.GetComponentContent("shell", projectInfo.Platform); if (content == null) return; - content = content.Replace("YourProjectNamespace", projectInfo.RootNamespace); + content = TemplateText.Apply(content, projectInfo.RootNamespace, componentsFolder); var filePath = Path.Combine(componentsPath, "Shell.cs"); await File.WriteAllTextAsync(filePath, content); diff --git a/src/ShellUI.Native.CLI/Services/TemplateText.cs b/src/ShellUI.Native.CLI/Services/TemplateText.cs new file mode 100644 index 0000000..106cb20 --- /dev/null +++ b/src/ShellUI.Native.CLI/Services/TemplateText.cs @@ -0,0 +1,22 @@ +namespace ShellUI.Native.CLI.Services; + +// Turns template text into code for one project. The placeholder namespace becomes the project's +// root namespace plus the components folder: Components/UI → .Components.UI, and +// Components/Native → .Components.Native. +public static class TemplateText +{ + public const string DefaultComponentsPath = "Components/UI"; + + // Used when the project also has ShellUI (Blazor), whose components live in Components/UI. + public const string BesideBlazorComponentsPath = "Components/Native"; + + public static string ComponentsNamespace(string rootNamespace, string componentsPath) + { + var folder = componentsPath.Replace('\\', '/').Trim('/').Replace('/', '.'); + return folder.Length == 0 ? rootNamespace : $"{rootNamespace}.{ProjectDetector.SafeNamespace(folder)}"; + } + + public static string Apply(string content, string rootNamespace, string componentsPath) => + content.Replace("YourProjectNamespace.Components.UI", ComponentsNamespace(rootNamespace, componentsPath)) + .Replace("YourProjectNamespace", rootNamespace); +} diff --git a/tests/ShellUI.Native.Tests/TemplateTextTests.cs b/tests/ShellUI.Native.Tests/TemplateTextTests.cs new file mode 100644 index 0000000..bb6f06e --- /dev/null +++ b/tests/ShellUI.Native.Tests/TemplateTextTests.cs @@ -0,0 +1,44 @@ +using ShellUI.Native.CLI.Services; +using ShellUI.Native.Core.Models; +using ShellUI.Native.Templates; + +namespace ShellUI.Native.Tests; + +// The components namespace follows the components folder, so ShellUI Native can sit next to +// ShellUI (Blazor) in one hybrid project without two classes named .Components.UI.Button (issue #28). +public class TemplateTextTests +{ + [Theory] + [InlineData("Components/UI", "MyApp.Components.UI")] + [InlineData("Components/Native", "MyApp.Components.Native")] + [InlineData("Components\\Native\\", "MyApp.Components.Native")] + [InlineData("UI", "MyApp.UI")] + [InlineData("Components/native-ui", "MyApp.Components.native_ui")] + [InlineData("", "MyApp")] + public void Components_namespace_follows_the_folder(string folder, string expected) + { + Assert.Equal(expected, TemplateText.ComponentsNamespace("MyApp", folder)); + } + + [Fact] + public void Default_folder_gives_the_same_code_as_before() + { + var content = ComponentRegistry.GetComponentContent("button", NativePlatform.MAUI)!; + Assert.Equal(content.Replace("YourProjectNamespace", "MyApp"), + TemplateText.Apply(content, "MyApp", TemplateText.DefaultComponentsPath)); + } + + [Fact] + public void Another_folder_moves_the_namespace_and_its_variants() + { + var button = TemplateText.Apply(ComponentRegistry.GetComponentContent("button", NativePlatform.MAUI)!, + "MyApp", TemplateText.BesideBlazorComponentsPath); + var variants = TemplateText.Apply(ComponentRegistry.GetComponentContent("button-variants", NativePlatform.MAUI)!, + "MyApp", TemplateText.BesideBlazorComponentsPath); + + Assert.Contains("namespace MyApp.Components.Native;", button); + Assert.Contains("namespace MyApp.Components.Native.Variants;", variants); + Assert.DoesNotContain("Components.UI", button + variants); + Assert.DoesNotContain("YourProjectNamespace", button + variants); + } +} From ad516770707c77b48408347a1478c5f85847843b Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 10:54:21 +0200 Subject: [PATCH 05/11] feat(cli): donut-chart alias and did-you-mean for unknown names add / remove / update accept donut-chart, which lives in the pie-chart template, and suggest up to three close names when a name isn't found (a typo, or a shared word such as chart-04). --- .../Services/ComponentInstaller.cs | 16 ++++++- .../Services/ComponentManager.cs | 8 ++-- .../ComponentRegistry.cs | 48 +++++++++++++++++++ .../ComponentRegistryTests.cs | 34 +++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs index 17bd1b7..67821fd 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs @@ -32,8 +32,11 @@ public static async Task InstallComponents(string[] components, bool force) // Parse comma-separated components; a part installs its whole family var componentList = new List(); - foreach (var comp in components.SelectMany(c => c.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) + foreach (var requested in components.SelectMany(c => c.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) { + var comp = ComponentRegistry.Resolve(requested); + if (comp != requested.ToLower()) + AnsiConsole.MarkupLine($"[dim]'{requested}' is in '{comp}', installing '{comp}'[/]"); var family = ComponentRegistry.GetFamily(comp); if (family != null) AnsiConsole.MarkupLine($"[dim]'{comp}' is part of '{family}', installing '{family}'[/]"); @@ -84,6 +87,15 @@ await InstallComponentWithDependenciesAsync( AnsiConsole.MarkupLine($"[red]Failed: {string.Join(", ", failedComponents)}[/]"); } + // " Did you mean 'x' or 'y'?" for an unknown name, or nothing when no name is close. + internal static string DidYouMean(string name) + { + var suggestions = ComponentRegistry.Suggest(name); + return suggestions.Count == 0 + ? string.Empty + : $" [yellow]Did you mean {string.Join(" or ", suggestions.Select(s => $"'{s}'"))}?[/]"; + } + private static async Task InstallComponentWithDependenciesAsync( string componentName, ShellUINativeConfig config, @@ -98,7 +110,7 @@ private static async Task InstallComponentWithDependenciesAsync( if (!ComponentRegistry.Exists(componentName)) { - AnsiConsole.MarkupLine($"[red]Component '{componentName}' not found[/]"); + AnsiConsole.MarkupLine($"[red]Component '{componentName}' not found[/]{DidYouMean(componentName)}"); failedComponents.Add(componentName); return; } diff --git a/src/ShellUI.Native.CLI/Services/ComponentManager.cs b/src/ShellUI.Native.CLI/Services/ComponentManager.cs index b23f547..7a99229 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentManager.cs @@ -87,12 +87,12 @@ public static void RemoveComponents(string[] components) var removedCount = 0; - foreach (var componentName in components) + foreach (var componentName in components.Select(ComponentRegistry.Resolve)) { var metadata = ComponentRegistry.GetMetadata(componentName); if (metadata == null) { - AnsiConsole.MarkupLine($"[yellow]Component '{componentName}' not found in registry[/]"); + AnsiConsole.MarkupLine($"[yellow]Component '{componentName}' not found in registry[/]{ComponentInstaller.DidYouMean(componentName)}"); continue; } @@ -147,7 +147,7 @@ public static void UpdateComponents(string[] components, bool all) } else { - componentsToUpdate = components; + componentsToUpdate = components.Select(ComponentRegistry.Resolve); } var updatedCount = 0; @@ -157,7 +157,7 @@ public static void UpdateComponents(string[] components, bool all) var metadata = ComponentRegistry.GetMetadata(componentName); if (metadata == null) { - AnsiConsole.MarkupLine($"[yellow]Component '{componentName}' not found in registry[/]"); + AnsiConsole.MarkupLine($"[yellow]Component '{componentName}' not found in registry[/]{ComponentInstaller.DidYouMean(componentName)}"); continue; } diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index 74d4ead..d41de0e 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -146,6 +146,12 @@ public static class ComponentRegistry { "radial-chart", (RadialChartTemplate.Metadata, RadialChartTemplate.Contents) } }; + // Names people try for a component that lives in another template. + private static readonly Dictionary _aliases = new() + { + { "donut-chart", "pie-chart" } + }; + // Metadata-only view for callers that don't need the per-platform payload. public static IReadOnlyDictionary Components { get; } = _templates.ToDictionary(kv => kv.Key, kv => kv.Value.Meta); @@ -195,6 +201,48 @@ public static bool Exists(string componentName) return _templates.ContainsKey(componentName.ToLower()); } + // The registry name for `name`: the template an alias points to, or `name` itself. + public static string Resolve(string name) + { + var key = name.ToLower(); + return _aliases.TryGetValue(key, out var target) ? target : key; + } + + // Up to `max` registered names close to an unknown `name`, closest first: a typo + // ("buton"), or a shared word ("chart-04", "chart-bar"). + public static IReadOnlyList Suggest(string name, int max = 3) + { + var key = name.ToLower(); + var limit = Math.Max(2, key.Length / 3); + var words = key.Split('-', StringSplitOptions.RemoveEmptyEntries).Where(w => w.Length >= 3).ToList(); + return _templates.Keys.Concat(_aliases.Keys) + .Select(candidate => (candidate, distance: Distance(key, candidate))) + .Where(c => c.distance <= limit || words.Any(c.candidate.Split('-').Contains)) + .OrderBy(c => c.distance) + .ThenBy(c => c.candidate, StringComparer.Ordinal) + .Select(c => c.candidate) + .Take(max) + .ToList(); + } + + // Levenshtein edit distance. + private static int Distance(string a, string b) + { + var row = Enumerable.Range(0, b.Length + 1).ToArray(); + for (var i = 1; i <= a.Length; i++) + { + var diagonal = row[0]; + row[0] = i; + for (var j = 1; j <= b.Length; j++) + { + var above = row[j]; + row[j] = Math.Min(Math.Min(row[j] + 1, row[j - 1] + 1), diagonal + (a[i - 1] == b[j - 1] ? 0 : 1)); + diagonal = above; + } + } + return row[b.Length]; + } + /* The family a compositional part belongs to (`dialog-trigger` → `dialog`), or null. Parts find their parent type at runtime and parents depend on their parts, so a part only builds when its family is installed. */ diff --git a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs index 8bb3882..3151b78 100644 --- a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs +++ b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs @@ -170,4 +170,38 @@ public void Component_version_is_the_package_version_from_Directory_Build_props( Assert.All(ComponentRegistry.Components.Values, m => Assert.Equal(expected, m.Version)); } + + [Theory] + [InlineData("donut-chart", "pie-chart")] + [InlineData("Donut-Chart", "pie-chart")] + [InlineData("button", "button")] + [InlineData("not-a-component", "not-a-component")] + public void Resolve_maps_aliases_to_their_template(string name, string expected) + { + Assert.Equal(expected, ComponentRegistry.Resolve(name)); + } + + [Fact] + public void Every_alias_points_to_a_registered_component() + { + foreach (var name in new[] { "donut-chart" }) + Assert.True(ComponentRegistry.Exists(ComponentRegistry.Resolve(name)), name); + } + + [Theory] + [InlineData("buton", "button")] + [InlineData("chart-bar", "bar-chart")] + [InlineData("chart-04", "chart")] + [InlineData("datepicker", "date-picker")] + public void Suggest_finds_close_names(string name, string expected) + { + Assert.Contains(expected, ComponentRegistry.Suggest(name)); + } + + [Fact] + public void Suggest_returns_at_most_three_and_nothing_for_unrelated_names() + { + Assert.True(ComponentRegistry.Suggest("chart").Count <= 3); + Assert.Empty(ComponentRegistry.Suggest("zzqqxx")); + } } From 76982074fae49b56966b9da19ea6c33295f0f8a0 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 11:28:30 +0200 Subject: [PATCH 06/11] fix: change events report user input only Toggled, CheckedChanged, ValueChanged, SelectedIndexChanged, SelectedValueChanged, PageChanged, DateChanged, TimeChanged and the other value and selection events now fire only for clicks, taps, swipes and keys, like Radix's onValueChange. Setting the value from code or a binding updates the control without raising them, so filling a form or syncing a sidebar with the router no longer looks like user input. Watch the property to see every change. Covers 30 components on MAUI and Avalonia. Unchanged: text fields' TextChanged, overlay open events, ThemeChanged, FileUpload.FilesChanged and Sidebar.IsCollapsedChanged. Closes #24. --- .../Avalonia.Demo/Components/UI/Accordion.cs | 20 ++++++- .../Components/UI/AccordionItem.cs | 5 +- .../Components/UI/AccordionTrigger.cs | 2 +- .../Avalonia.Demo/Components/UI/Carousel.cs | 29 +++++++--- .../Avalonia.Demo/Components/UI/Checkbox.cs | 18 +++++-- .../Components/UI/Collapsible.cs | 17 +++++- .../Components/UI/CollapsibleTrigger.cs | 2 +- .../Avalonia.Demo/Components/UI/Combobox.cs | 16 +++++- .../Avalonia.Demo/Components/UI/DataTable.cs | 18 +++++-- .../Avalonia.Demo/Components/UI/DatePicker.cs | 16 +++++- .../Components/UI/DateRangePicker.cs | 18 +++++-- .../Avalonia.Demo/Components/UI/InputOtp.cs | 15 +++++- .../Components/UI/MultiSelect.cs | 23 ++++++-- .../Avalonia.Demo/Components/UI/Navbar.cs | 16 +++++- .../Components/UI/NumberInput.cs | 20 +++++-- .../Avalonia.Demo/Components/UI/Pagination.cs | 23 ++++++-- .../Avalonia.Demo/Components/UI/RadioGroup.cs | 17 +++++- .../Components/UI/RadioGroupItem.cs | 2 +- .../Avalonia.Demo/Components/UI/Select.cs | 16 +++++- .../Avalonia.Demo/Components/UI/Sidebar.cs | 19 ++++++- .../Avalonia.Demo/Components/UI/Slider.cs | 18 +++++-- .../Avalonia.Demo/Components/UI/Stepper.cs | 22 ++++++-- .../Avalonia.Demo/Components/UI/Switch.cs | 18 +++++-- examples/Avalonia.Demo/Components/UI/Tabs.cs | 17 +++++- .../Components/UI/TabsTrigger.cs | 2 +- .../Avalonia.Demo/Components/UI/TagInput.cs | 22 ++++++-- .../Avalonia.Demo/Components/UI/TimePicker.cs | 20 +++++-- .../Avalonia.Demo/Components/UI/Toggle.cs | 18 +++++-- .../Components/UI/ToggleGroup.cs | 21 ++++++-- .../Avalonia.Demo/Components/UI/TreeView.cs | 19 ++++++- examples/MAUI.Demo/Components/UI/Accordion.cs | 20 ++++++- .../MAUI.Demo/Components/UI/AccordionItem.cs | 5 +- .../Components/UI/AccordionTrigger.cs | 2 +- examples/MAUI.Demo/Components/UI/Carousel.cs | 25 ++++++--- examples/MAUI.Demo/Components/UI/Checkbox.cs | 18 +++++-- .../MAUI.Demo/Components/UI/Collapsible.cs | 17 +++++- .../Components/UI/CollapsibleTrigger.cs | 2 +- examples/MAUI.Demo/Components/UI/Combobox.cs | 16 +++++- examples/MAUI.Demo/Components/UI/DataTable.cs | 18 +++++-- .../MAUI.Demo/Components/UI/DatePicker.cs | 16 +++++- .../Components/UI/DateRangePicker.cs | 18 +++++-- examples/MAUI.Demo/Components/UI/InputOtp.cs | 15 +++++- .../MAUI.Demo/Components/UI/MultiSelect.cs | 23 ++++++-- examples/MAUI.Demo/Components/UI/Navbar.cs | 16 +++++- .../MAUI.Demo/Components/UI/NumberInput.cs | 20 +++++-- .../MAUI.Demo/Components/UI/Pagination.cs | 23 ++++++-- .../MAUI.Demo/Components/UI/RadioGroup.cs | 17 +++++- .../MAUI.Demo/Components/UI/RadioGroupItem.cs | 2 +- examples/MAUI.Demo/Components/UI/Select.cs | 16 +++++- examples/MAUI.Demo/Components/UI/Sidebar.cs | 19 ++++++- examples/MAUI.Demo/Components/UI/Slider.cs | 17 ++++-- examples/MAUI.Demo/Components/UI/Stepper.cs | 20 +++++-- examples/MAUI.Demo/Components/UI/Switch.cs | 18 +++++-- examples/MAUI.Demo/Components/UI/Tabs.cs | 17 +++++- .../MAUI.Demo/Components/UI/TabsTrigger.cs | 2 +- examples/MAUI.Demo/Components/UI/TagInput.cs | 20 +++++-- .../MAUI.Demo/Components/UI/TimePicker.cs | 20 +++++-- examples/MAUI.Demo/Components/UI/Toggle.cs | 16 +++++- .../MAUI.Demo/Components/UI/ToggleGroup.cs | 21 ++++++-- examples/MAUI.Demo/Components/UI/TreeView.cs | 19 ++++++- .../Templates/AccordionItemTemplate.cs | 10 +++- .../Templates/AccordionTemplate.cs | 40 +++++++++++++- .../Templates/AccordionTriggerTemplate.cs | 4 +- .../Templates/CarouselTemplate.cs | 54 ++++++++++++++----- .../Templates/CheckboxTemplate.cs | 36 ++++++++++--- .../Templates/CollapsibleTemplate.cs | 34 +++++++++++- .../Templates/CollapsibleTriggerTemplate.cs | 4 +- .../Templates/ComboboxTemplate.cs | 32 +++++++++-- .../Templates/DataTableTemplate.cs | 36 ++++++++++--- .../Templates/DatePickerTemplate.cs | 32 +++++++++-- .../Templates/DateRangePickerTemplate.cs | 36 ++++++++++--- .../Templates/InputOtpTemplate.cs | 30 ++++++++++- .../Templates/MultiSelectTemplate.cs | 46 +++++++++++++--- .../Templates/NavbarTemplate.cs | 32 +++++++++-- .../Templates/NumberInputTemplate.cs | 40 +++++++++++--- .../Templates/PaginationTemplate.cs | 46 +++++++++++++--- .../Templates/RadioGroupItemTemplate.cs | 4 +- .../Templates/RadioGroupTemplate.cs | 34 +++++++++++- .../Templates/SelectTemplate.cs | 32 +++++++++-- .../Templates/SidebarTemplate.cs | 38 +++++++++++-- .../Templates/SliderTemplate.cs | 35 +++++++++--- .../Templates/StepperTemplate.cs | 42 +++++++++++---- .../Templates/SwitchTemplate.cs | 36 ++++++++++--- .../Templates/TabsTemplate.cs | 34 +++++++++++- .../Templates/TabsTriggerTemplate.cs | 4 +- .../Templates/TagInputTemplate.cs | 42 ++++++++++++--- .../Templates/TimePickerTemplate.cs | 40 +++++++++++--- .../Templates/ToggleGroupTemplate.cs | 42 ++++++++++++--- .../Templates/ToggleTemplate.cs | 34 ++++++++++-- .../Templates/TreeViewTemplate.cs | 38 +++++++++++-- 90 files changed, 1644 insertions(+), 290 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/Accordion.cs b/examples/Avalonia.Demo/Components/UI/Accordion.cs index d3245d0..410c5f9 100644 --- a/examples/Avalonia.Demo/Components/UI/Accordion.cs +++ b/examples/Avalonia.Demo/Components/UI/Accordion.cs @@ -44,8 +44,12 @@ public string? Value } // Fired when any item's open state changes: (value, isOpen). + // User input only: setting Value, or calling Toggle, from code doesn't raise it. public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Accordion() { Children.CollectionChanged += (_, _) => Refresh(animate: false); @@ -53,6 +57,20 @@ public Accordion() public bool IsOpen(string value) => _openValues.Contains(value); + // Read by items, whose OpenChanged follows the same rule. + internal bool IsUserChange => _byUser; + + // A click or key on a trigger. + internal void ToggleByUser(string value) => ByUser(() => Toggle(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string value) { var wasOpen = _openValues.Contains(value); @@ -72,7 +90,7 @@ public void Toggle(string value) changed.Add((value, !wasOpen)); Refresh(animate: true); - foreach (var change in changed) ItemToggled?.Invoke(this, change); + if (_byUser) foreach (var change in changed) ItemToggled?.Invoke(this, change); } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) diff --git a/examples/Avalonia.Demo/Components/UI/AccordionItem.cs b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs index 2e8ac08..879f690 100644 --- a/examples/Avalonia.Demo/Components/UI/AccordionItem.cs +++ b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs @@ -25,6 +25,7 @@ public string? Value public bool IsOpen { get; private set; } + // User input only, like the accordion's ItemToggled. public event EventHandler? OpenChanged; public AccordionItem() @@ -35,6 +36,8 @@ public AccordionItem() public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + internal void ToggleByUser() => this.FindParentOfType()?.ToggleByUser(Value ?? string.Empty); + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; internal void ApplyOpen(bool open, bool animate) @@ -45,6 +48,6 @@ internal void ApplyOpen(bool open, bool animate) trigger.SetOpen(open, animate); foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) content.Apply(open, animate); - if (changed) OpenChanged?.Invoke(this, open); + if (changed && this.FindParentOfType() is { IsUserChange: true }) OpenChanged?.Invoke(this, open); } } diff --git a/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs index f0aff14..a1a47ca 100644 --- a/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs +++ b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs @@ -67,7 +67,7 @@ public AccordionTrigger() HorizontalAlignment = HorizontalAlignment.Stretch; } - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); protected override void OnPointerEntered(PointerEventArgs e) { diff --git a/examples/Avalonia.Demo/Components/UI/Carousel.cs b/examples/Avalonia.Demo/Components/UI/Carousel.cs index 87d6a81..2ddc737 100644 --- a/examples/Avalonia.Demo/Components/UI/Carousel.cs +++ b/examples/Avalonia.Demo/Components/UI/Carousel.cs @@ -115,8 +115,13 @@ public double AspectRatio [Content] public Controls Slides => _track.Children; + // User input only (arrows, dots, swipes, keys): auto-play, Next / Previous and setting Position + // from code don't raise it. public event EventHandler? PositionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Carousel() { // Transparent, not null, so a drag can start between the slides' own content. @@ -128,8 +133,8 @@ public Carousel() _track.PointerReleased += OnTrackReleased; _track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true); - _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", Previous); - _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", Next); + _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", () => ByUser(Previous)); + _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", () => ByUser(Next)); foreach (var arrow in new[] { _previous, _next }) { arrow.PointerEntered += (_, _) => UpdateControls(); @@ -153,6 +158,14 @@ public Carousel() private int Count => _track.Children.Count; private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1)); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (Current < Count - 1) Position = Current + 1; @@ -192,8 +205,8 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Handled || e.Source is TextBox) return; - if (e.Key == Key.Left) Previous(); - else if (e.Key == Key.Right) Next(); + if (e.Key == Key.Left) ByUser(Previous); + else if (e.Key == Key.Right) ByUser(Next); else return; e.Handled = true; } @@ -258,7 +271,7 @@ private void OnSlidesChanged() target.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - Position = index; + ByUser(() => Position = index); e.Handled = true; }; _dots.Children.Add(target); @@ -274,7 +287,7 @@ private void OnPositionChanged() { UpdateControls(); AnimateTo(Current, 300); - PositionChanged?.Invoke(this, Current); + if (_byUser) PositionChanged?.Invoke(this, Current); // A manual move restarts the auto-play countdown. UpdateTimer(); } @@ -383,8 +396,8 @@ private void EndDrag(bool cancelled) if (!wasDragging) return; var current = Current; - if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1; - else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1; + if (!cancelled && moved <= -0.2 && current < Count - 1) ByUser(() => Position = current + 1); + else if (!cancelled && moved >= 0.2 && current > 0) ByUser(() => Position = current - 1); // Not far enough: settle back. else AnimateTo(current, 200); } diff --git a/examples/Avalonia.Demo/Components/UI/Checkbox.cs b/examples/Avalonia.Demo/Components/UI/Checkbox.cs index a4a9ee5..d04093b 100644 --- a/examples/Avalonia.Demo/Components/UI/Checkbox.cs +++ b/examples/Avalonia.Demo/Components/UI/Checkbox.cs @@ -30,7 +30,7 @@ static Checkbox() IsCheckedProperty.Changed.AddClassHandler((c, e) => { c.UpdateVisualState(); - c.CheckedChanged?.Invoke(c, (bool)e.NewValue!); + if (c._byUser) c.CheckedChanged?.Invoke(c, (bool)e.NewValue!); }); LabelProperty.Changed.AddClassHandler((c, _) => c.UpdateLabel()); HasErrorProperty.Changed.AddClassHandler((c, _) => c.UpdateVisualState()); @@ -55,8 +55,12 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting IsChecked from code or a binding doesn't raise it. public event EventHandler? CheckedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Checkbox() { _check = new Icon { Kind = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground }; @@ -80,11 +84,19 @@ public Checkbox() UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsChecked = !IsChecked; + ByUser(() => IsChecked = !IsChecked); e.Handled = true; } @@ -92,7 +104,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key != Key.Space) return; - IsChecked = !IsChecked; + ByUser(() => IsChecked = !IsChecked); e.Handled = true; } diff --git a/examples/Avalonia.Demo/Components/UI/Collapsible.cs b/examples/Avalonia.Demo/Components/UI/Collapsible.cs index 09f9de7..93e6c8b 100644 --- a/examples/Avalonia.Demo/Components/UI/Collapsible.cs +++ b/examples/Avalonia.Demo/Components/UI/Collapsible.cs @@ -20,7 +20,7 @@ static Collapsible() OpenProperty.Changed.AddClassHandler((c, _) => { c.Refresh(animate: c.IsLoaded); - c.OpenChanged?.Invoke(c, c.Open); + if (c._byUser) c.OpenChanged?.Invoke(c, c.Open); }); } @@ -30,8 +30,12 @@ public bool Open set => SetValue(OpenProperty, value); } + // User input only: setting Open, or calling Toggle / SetOpen, from code doesn't raise it. public event EventHandler? OpenChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Collapsible() { Spacing = 8; @@ -42,6 +46,17 @@ public Collapsible() public void Toggle() => Open = !Open; + // A click or key on the trigger. + internal void ToggleByUser() => ByUser(Toggle); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs index 98f1172..d8f3ab6 100644 --- a/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs +++ b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs @@ -3,5 +3,5 @@ namespace AvaloniaDemo.Components.UI; // Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). public class CollapsibleTrigger : ShellTriggerView { - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); } diff --git a/examples/Avalonia.Demo/Components/UI/Combobox.cs b/examples/Avalonia.Demo/Components/UI/Combobox.cs index ee93735..335aadd 100644 --- a/examples/Avalonia.Demo/Components/UI/Combobox.cs +++ b/examples/Avalonia.Demo/Components/UI/Combobox.cs @@ -52,7 +52,7 @@ static Combobox() { c.UpdateTrigger(); c.UpdateChecks(); - c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); + if (c._byUser) c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); }); ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems()); PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger()); @@ -93,8 +93,12 @@ public string? EmptyText public bool IsOpen => _popup.IsOpen; + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Combobox() { _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -209,9 +213,17 @@ private void OnSearchKeyDown(object? sender, KeyEventArgs e) e.Handled = true; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void Pick(string text) { - Value = text; + ByUser(() => Value = text); SetOpen(false); } diff --git a/examples/Avalonia.Demo/Components/UI/DataTable.cs b/examples/Avalonia.Demo/Components/UI/DataTable.cs index 41145d3..5c4feae 100644 --- a/examples/Avalonia.Demo/Components/UI/DataTable.cs +++ b/examples/Avalonia.Demo/Components/UI/DataTable.cs @@ -151,8 +151,12 @@ public string Filter set => _filter.Text = value; } + // User input only: ClearSelection and changes from code don't raise it. public event EventHandler>? SelectionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + // Raised with the row's item. public event EventHandler? RowTapped; @@ -220,6 +224,14 @@ public void SortBy(DataTableColumn? column, bool descending = false) Refresh(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void ClearSelection() { if (_selected.Count == 0) return; @@ -259,7 +271,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.UnionWith(_view); else _selected.ExceptWith(_view); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; header.Children.Add(all); _allCheck = all; @@ -286,7 +298,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.Add(item); else _selected.Remove(item); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; row.Children.Add(check); } @@ -334,7 +346,7 @@ private void ShowSelectionState() private void OnSelectionChanged() { ShowSelectionState(); - SelectionChanged?.Invoke(this, SelectedItems); + if (_byUser) SelectionChanged?.Invoke(this, SelectedItems); } // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction diff --git a/examples/Avalonia.Demo/Components/UI/DatePicker.cs b/examples/Avalonia.Demo/Components/UI/DatePicker.cs index 82495fb..4d0dabc 100644 --- a/examples/Avalonia.Demo/Components/UI/DatePicker.cs +++ b/examples/Avalonia.Demo/Components/UI/DatePicker.cs @@ -38,7 +38,7 @@ static DatePicker() { p.UpdateText(); p._calendar.SelectedDate = (DateTime)e.NewValue!; - p.DateChanged?.Invoke(p, (DateTime)e.NewValue!); + if (p._byUser) p.DateChanged?.Invoke(p, (DateTime)e.NewValue!); }); MinimumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MinimumDate = (DateTime?)e.NewValue); MaximumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MaximumDate = (DateTime?)e.NewValue); @@ -73,8 +73,20 @@ public string Format public bool IsOpen => _popup.IsOpen; // The new date. + // User input only: setting Date from code or a binding doesn't raise it. public event EventHandler? DateChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public DatePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; @@ -101,7 +113,7 @@ public DatePicker() _calendar = new Calendar { SelectedDate = Date }; _calendar.DateSelected += (_, date) => { - Date = date; + ByUser(() => Date = date); SetOpen(false); }; var panel = new Border diff --git a/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs b/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs index 02c944a..058285f 100644 --- a/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs +++ b/examples/Avalonia.Demo/Components/UI/DateRangePicker.cs @@ -102,8 +102,12 @@ public DateTime? MaximumDate public bool IsOpen => _popup.IsOpen; // Raised when StartDate or EndDate changes. + // User input only: setting StartDate / EndDate, or calling Clear, from code doesn't raise it. public event EventHandler? RangeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public DateRangePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -119,7 +123,7 @@ public DateRangePicker() _clear.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Clear(); + if (IsEffectivelyEnabled) ByUser(Clear); e.Handled = true; }; Grid.SetColumn(_clear, 2); @@ -149,7 +153,7 @@ public DateRangePicker() }; _calendar = new Calendar(); - _calendar.DateSelected += (_, date) => Pick(date); + _calendar.DateSelected += (_, date) => ByUser(() => Pick(date)); var panel = new Border { Child = _calendar, @@ -186,6 +190,14 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Clear() { StartDate = null; @@ -218,7 +230,7 @@ private void OnRangeChanged() { UpdateTrigger(); UpdateCalendar(); - RangeChanged?.Invoke(this, EventArgs.Empty); + if (_byUser) RangeChanged?.Invoke(this, EventArgs.Empty); } private void UpdateCalendar() diff --git a/examples/Avalonia.Demo/Components/UI/InputOtp.cs b/examples/Avalonia.Demo/Components/UI/InputOtp.cs index 8d8c982..30f4652 100644 --- a/examples/Avalonia.Demo/Components/UI/InputOtp.cs +++ b/examples/Avalonia.Demo/Components/UI/InputOtp.cs @@ -71,10 +71,14 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting Value from code or a binding doesn't raise ValueChanged or Completed. public event EventHandler? ValueChanged; // Raised when every slot is filled. public event EventHandler? Completed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public InputOtp() { // The real input: stretched over the slots and invisible, so a click anywhere focuses it. @@ -90,7 +94,7 @@ public InputOtp() _textBox.Text = value; _textBox.CaretIndex = value.Length; } - Value = value; + ByUser(() => Value = value); }; _textBox.GotFocus += (_, _) => { @@ -150,6 +154,14 @@ private void BuildSlots() OnValueChanged(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged() { var value = Sanitize(Value); @@ -164,6 +176,7 @@ private void OnValueChanged() _textBox.CaretIndex = value.Length; } UpdateSlots(); + if (!_byUser) return; ValueChanged?.Invoke(this, value); if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); } diff --git a/examples/Avalonia.Demo/Components/UI/MultiSelect.cs b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs index 26da092..9f11ae0 100644 --- a/examples/Avalonia.Demo/Components/UI/MultiSelect.cs +++ b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs @@ -93,8 +93,12 @@ public string? EmptyText public bool IsOpen => _popup.IsOpen; + // User input only: setting Values, or calling Toggle, from code doesn't raise it. public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public MultiSelect() { _placeholder = new TextBlock { Text = Placeholder, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0) }; @@ -200,6 +204,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); // Adds the option when it isn't picked, removes it when it is. + // A click, a tap or Enter on a row, or the × on a chip. + private void ToggleByUser(string text) => ByUser(() => Toggle(text)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string text) { // A new list each time, so a two-way binding sees the change. @@ -221,7 +236,7 @@ private void OnSearchKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.Enter) { - if (_active >= 0) Toggle(_rows[_active].Text); + if (_active >= 0) ToggleByUser(_rows[_active].Text); } else if (e.Key == Key.Down) MoveActive(1); else if (e.Key == Key.Up) MoveActive(-1); @@ -240,7 +255,7 @@ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e { RebuildChips(); UpdateChecks(); - ValuesChanged?.Invoke(this, Current); + if (_byUser) ValuesChanged?.Invoke(this, Current); } private void RebuildChips() @@ -266,7 +281,7 @@ private Control CreateChip(string value) remove.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Toggle(value); + if (IsEffectivelyEnabled) ToggleByUser(value); e.Handled = true; }; @@ -318,7 +333,7 @@ private Control CreateItem(string text, int index) item.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - Toggle(text); + ToggleByUser(text); e.Handled = true; }; diff --git a/examples/Avalonia.Demo/Components/UI/Navbar.cs b/examples/Avalonia.Demo/Components/UI/Navbar.cs index 9130fac..4225943 100644 --- a/examples/Avalonia.Demo/Components/UI/Navbar.cs +++ b/examples/Avalonia.Demo/Components/UI/Navbar.cs @@ -71,7 +71,7 @@ static Navbar() SelectedValueProperty.Changed.AddClassHandler((n, _) => { n.Refresh(); - n.SelectedValueChanged?.Invoke(n, n.SelectedValue); + if (n._byUser) n.SelectedValueChanged?.Invoke(n, n.SelectedValue); }); } @@ -121,8 +121,12 @@ public bool IsMenuOpen [Content] public Controls Items { get; } = new(); + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Navbar() { _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; @@ -189,9 +193,17 @@ public Navbar() private List ItemList => Items.OfType().ToList(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + internal void Select(NavbarItem item) { - if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + if (!string.IsNullOrEmpty(item.Value)) ByUser(() => SelectedValue = item.Value); IsMenuOpen = false; } diff --git a/examples/Avalonia.Demo/Components/UI/NumberInput.cs b/examples/Avalonia.Demo/Components/UI/NumberInput.cs index d0be2ca..446d0af 100644 --- a/examples/Avalonia.Demo/Components/UI/NumberInput.cs +++ b/examples/Avalonia.Demo/Components/UI/NumberInput.cs @@ -42,7 +42,7 @@ static NumberInput() { n.UpdateText(); n.UpdateButtons(); - n.ValueChanged?.Invoke(n, n.Value); + if (n._byUser) n.ValueChanged?.Invoke(n, n.Value); }); MinimumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); MaximumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); @@ -80,8 +80,12 @@ public string? Placeholder set => SetValue(PlaceholderProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public NumberInput() { _textBox = new TextBox @@ -166,7 +170,15 @@ private void StepBy(int direction) { if (!IsEffectivelyEnabled) return; Commit(); - Value = (Value ?? Minimum ?? 0) + direction * Step; + ByUser(() => Value = (Value ?? Minimum ?? 0) + direction * Step); + } + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } private decimal? Clamp(decimal? value) @@ -178,7 +190,9 @@ private void StepBy(int direction) } // A typed number is clamped and kept, empty clears the value, anything else is reverted. - private void Commit() + private void Commit() => ByUser(CommitText); + + private void CommitText() { var text = _textBox.Text?.Trim(); if (string.IsNullOrEmpty(text)) Value = null; diff --git a/examples/Avalonia.Demo/Components/UI/Pagination.cs b/examples/Avalonia.Demo/Components/UI/Pagination.cs index b870ab2..37bcb9e 100644 --- a/examples/Avalonia.Demo/Components/UI/Pagination.cs +++ b/examples/Avalonia.Demo/Components/UI/Pagination.cs @@ -41,7 +41,7 @@ static Pagination() PageProperty.Changed.AddClassHandler((p, _) => { p.Rebuild(); - p.PageChanged?.Invoke(p, p.Page); + if (p._byUser) p.PageChanged?.Invoke(p, p.Page); }); TotalPagesProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); SiblingCountProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); @@ -72,8 +72,12 @@ public bool ShowLabels set => SetValue(ShowLabelsProperty, value); } + // User input only: setting Page from code or a binding doesn't raise it. public event EventHandler? PageChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Pagination() { Child = _row; @@ -81,6 +85,17 @@ public Pagination() Rebuild(); } + // A click or key on a page or an arrow. + private void GoTo(int page) => ByUser(() => Page = page); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // Page is not clamped when set (XAML may set Page before TotalPages), only when shown. private int Total => Math.Max(1, TotalPages); private int Current => Math.Clamp(Page, 1, Total); @@ -107,10 +122,10 @@ private void Rebuild() // The rebuild replaces the focused item, so keyboard focus moves to the new current page. var hadFocus = _row.IsKeyboardFocusWithin; _row.Children.Clear(); - _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => Page = Current - 1)); + _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => GoTo(Current - 1))); foreach (var page in VisiblePages()) _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page)); - _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => Page = Current + 1)); + _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => GoTo(Current + 1))); if (hadFocus) _currentItem?.Focus(NavigationMethod.Tab); } @@ -119,7 +134,7 @@ private Control PageItem(int page) var current = page == Current; var label = new TextBlock { Text = page.ToString(CultureInfo.CurrentCulture), FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); - var item = Item(label, ItemSize, new Thickness(0), current ? null : () => Page = page, focusable: true); + var item = Item(label, ItemSize, new Thickness(0), current ? null : () => GoTo(page), focusable: true); AutomationProperties.SetName(item, current ? $"Page {page}, current page" : $"Go to page {page}"); if (current) { diff --git a/examples/Avalonia.Demo/Components/UI/RadioGroup.cs b/examples/Avalonia.Demo/Components/UI/RadioGroup.cs index 27fefbe..867f31b 100644 --- a/examples/Avalonia.Demo/Components/UI/RadioGroup.cs +++ b/examples/Avalonia.Demo/Components/UI/RadioGroup.cs @@ -17,7 +17,7 @@ static RadioGroup() ValueProperty.Changed.AddClassHandler((g, e) => { g.RefreshItems(); - g.ValueChanged?.Invoke(g, (string?)e.NewValue ?? string.Empty); + if (g._byUser) g.ValueChanged?.Invoke(g, (string?)e.NewValue ?? string.Empty); }); } @@ -27,8 +27,12 @@ public string? Value set => SetValue(ValueProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public RadioGroup() { Spacing = 12; @@ -37,6 +41,17 @@ public RadioGroup() public void SetValue(string? value) => Value = value; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // Items inside a nested panel may arrive after this group's own children. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { diff --git a/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs b/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs index e0c1346..e9cd3e3 100644 --- a/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs +++ b/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs @@ -97,7 +97,7 @@ protected override void OnKeyDown(KeyEventArgs e) e.Handled = true; } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetChecked(bool isChecked) { diff --git a/examples/Avalonia.Demo/Components/UI/Select.cs b/examples/Avalonia.Demo/Components/UI/Select.cs index 86d0afd..e325e24 100644 --- a/examples/Avalonia.Demo/Components/UI/Select.cs +++ b/examples/Avalonia.Demo/Components/UI/Select.cs @@ -38,7 +38,7 @@ static Select() { s.UpdateTrigger(); s.UpdateItemStates(); - s.SelectedIndexChanged?.Invoke(s, EventArgs.Empty); + if (s._byUser) s.SelectedIndexChanged?.Invoke(s, EventArgs.Empty); }); ItemsSourceProperty.Changed.AddClassHandler((s, _) => s.UpdateTrigger()); @@ -68,8 +68,20 @@ public string? Placeholder public bool IsOpen => _popup.IsOpen; + // User input only: setting SelectedIndex from code or a binding doesn't raise it. public event EventHandler? SelectedIndexChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public Select() { _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -176,7 +188,7 @@ private Control CreateItem(string text, int index) item.PointerExited += (_, _) => { item.ClearToken(BackgroundProperty); item.Background = Brushes.Transparent; }; item.PointerReleased += (_, e) => { - SelectedIndex = index; + ByUser(() => SelectedIndex = index); SetOpen(false); e.Handled = true; }; diff --git a/examples/Avalonia.Demo/Components/UI/Sidebar.cs b/examples/Avalonia.Demo/Components/UI/Sidebar.cs index e58d6e6..2bebee3 100644 --- a/examples/Avalonia.Demo/Components/UI/Sidebar.cs +++ b/examples/Avalonia.Demo/Components/UI/Sidebar.cs @@ -66,7 +66,7 @@ static Sidebar() SelectedValueProperty.Changed.AddClassHandler((s, _) => { s.Refresh(); - s.SelectedValueChanged?.Invoke(s, s.SelectedValue); + if (s._byUser) s.SelectedValueChanged?.Invoke(s, s.SelectedValue); }); ExpandedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); CollapsedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); @@ -122,8 +122,12 @@ public Control? Footer public Controls Groups => _groups.Children; public event EventHandler? IsCollapsedChanged; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Sidebar() { _groups.Children.CollectionChanged += (_, _) => Refresh(); @@ -149,6 +153,17 @@ public Sidebar() public void Toggle() => IsCollapsed = !IsCollapsed; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // The width animates only once the sidebar is on screen, so it doesn't slide in at startup. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { @@ -352,7 +367,7 @@ protected override void OnKeyDown(KeyEventArgs e) public void Press() { if (!IsEffectivelyEnabled) return; - if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectByUser(Value); Clicked?.Invoke(this, EventArgs.Empty); } diff --git a/examples/Avalonia.Demo/Components/UI/Slider.cs b/examples/Avalonia.Demo/Components/UI/Slider.cs index 81fe049..e32ae92 100644 --- a/examples/Avalonia.Demo/Components/UI/Slider.cs +++ b/examples/Avalonia.Demo/Components/UI/Slider.cs @@ -39,7 +39,7 @@ static Slider() ValueProperty.Changed.AddClassHandler((s, e) => { s.UpdateLayoutOfParts(); - s.ValueChanged?.Invoke(s, (double)e.NewValue!); + if (s._byUser) s.ValueChanged?.Invoke(s, (double)e.NewValue!); }); MinimumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); MaximumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); @@ -70,8 +70,20 @@ public double Step set => SetValue(StepProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public Slider() { _track = new Border { Height = TrackHeight, CornerRadius = new CornerRadius(TrackHeight / 2), VerticalAlignment = VerticalAlignment.Center }; @@ -113,7 +125,7 @@ private void SetFromPointer(PointerEventArgs e) var travel = Bounds.Width - ThumbSize; if (travel <= 0) return; var fraction = Math.Clamp((e.GetPosition(this).X - ThumbSize / 2) / travel, 0, 1); - Value = Minimum + fraction * (Maximum - Minimum); + ByUser(() => Value = Minimum + fraction * (Maximum - Minimum)); } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -161,7 +173,7 @@ protected override void OnKeyDown(KeyEventArgs e) _ => null }; if (target is not { } value) return; - Value = value; + ByUser(() => Value = value); e.Handled = true; } } diff --git a/examples/Avalonia.Demo/Components/UI/Stepper.cs b/examples/Avalonia.Demo/Components/UI/Stepper.cs index f30fd31..ff699c3 100644 --- a/examples/Avalonia.Demo/Components/UI/Stepper.cs +++ b/examples/Avalonia.Demo/Components/UI/Stepper.cs @@ -112,14 +112,18 @@ public string ConfirmText [Content] public Controls Steps => _body.Children; + // User input only: setting CurrentStep, or calling Next / Previous, from code doesn't raise it. public event EventHandler? CurrentStepChanged; // Raised by the Confirm button on the last step. public event EventHandler? Confirmed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Stepper() { _previous = new Button { Variant = ButtonVariant.Outline, HorizontalAlignment = HorizontalAlignment.Left }; - _previous.Clicked += (_, _) => Previous(); + _previous.Clicked += (_, _) => ByUser(Previous); _next = new Button { HorizontalAlignment = HorizontalAlignment.Right }; _next.Clicked += (_, _) => { @@ -128,7 +132,7 @@ public Stepper() _highest = Math.Max(_highest, StepList.Count - 1); Confirmed?.Invoke(this, EventArgs.Empty); } - else Next(); + else ByUser(Next); }; _navigation = new Panel { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; @@ -141,6 +145,14 @@ public Stepper() private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, StepList.Count - 1)); private bool IsLast => Current >= StepList.Count - 1; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (!IsLast) CurrentStep = Current + 1; @@ -155,7 +167,7 @@ private void OnCurrentStepChanged() { _highest = Math.Max(_highest, Current); UpdateState(); - CurrentStepChanged?.Invoke(this, Current); + if (_byUser) CurrentStepChanged?.Invoke(this, Current); } private void Rebuild() @@ -194,13 +206,13 @@ private void Rebuild() circle.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left || index > _highest) return; - CurrentStep = index; + ByUser(() => CurrentStep = index); e.Handled = true; }; circle.KeyDown += (_, e) => { if (e.Key is not (Key.Enter or Key.Space) || index > _highest) return; - CurrentStep = index; + ByUser(() => CurrentStep = index); e.Handled = true; }; diff --git a/examples/Avalonia.Demo/Components/UI/Switch.cs b/examples/Avalonia.Demo/Components/UI/Switch.cs index 5562b96..9430ebe 100644 --- a/examples/Avalonia.Demo/Components/UI/Switch.cs +++ b/examples/Avalonia.Demo/Components/UI/Switch.cs @@ -36,7 +36,7 @@ static Switch() IsToggledProperty.Changed.AddClassHandler((s, e) => { s.UpdateVisualState(); - s.Toggled?.Invoke(s, (bool)e.NewValue!); + if (s._byUser) s.Toggled?.Invoke(s, (bool)e.NewValue!); }); LabelProperty.Changed.AddClassHandler((s, _) => s.UpdateLabel()); IsEnabledProperty.Changed.AddClassHandler((s, _) => s.Opacity = s.IsEnabled ? 1.0 : 0.5); @@ -54,8 +54,12 @@ public string? Label set => SetValue(LabelProperty, value); } + // User input only: setting IsToggled from code or a binding doesn't raise it. public event EventHandler? Toggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Switch() { _thumb = new Border @@ -95,11 +99,19 @@ public Switch() }; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsToggled = !IsToggled; + ByUser(() => IsToggled = !IsToggled); e.Handled = true; } @@ -107,7 +119,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key is not (Key.Space or Key.Enter)) return; - IsToggled = !IsToggled; + ByUser(() => IsToggled = !IsToggled); e.Handled = true; } diff --git a/examples/Avalonia.Demo/Components/UI/Tabs.cs b/examples/Avalonia.Demo/Components/UI/Tabs.cs index 080c74f..dea431e 100644 --- a/examples/Avalonia.Demo/Components/UI/Tabs.cs +++ b/examples/Avalonia.Demo/Components/UI/Tabs.cs @@ -24,7 +24,7 @@ static Tabs() ValueProperty.Changed.AddClassHandler((t, e) => { t.Refresh(animate: t.IsLoaded); - t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + if (t._byUser) t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); }); } @@ -35,8 +35,12 @@ public string? Value } // (oldValue, newValue) + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler<(string Old, string New)>? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Tabs() { Spacing = 8; @@ -45,6 +49,17 @@ public Tabs() public void SetValue(string? value) => Value = value; + // A click or key on a trigger. + internal void SelectByUser(string? value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs index 15d9386..e4bb333 100644 --- a/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs +++ b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs @@ -53,7 +53,7 @@ public TabsTrigger() SetActive(false); } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetActive(bool active) { diff --git a/examples/Avalonia.Demo/Components/UI/TagInput.cs b/examples/Avalonia.Demo/Components/UI/TagInput.cs index 1b60ed3..6787fe0 100644 --- a/examples/Avalonia.Demo/Components/UI/TagInput.cs +++ b/examples/Avalonia.Demo/Components/UI/TagInput.cs @@ -68,8 +68,12 @@ public bool AllowDuplicates set => SetValue(AllowDuplicatesProperty, value); } + // User input only: setting Tags, or calling Add / Remove, from code doesn't raise it. public event EventHandler>? TagsChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public TagInput() { _textBox = new TextBox @@ -90,7 +94,7 @@ public TagInput() } else if (e.Key == Key.Back && string.IsNullOrEmpty(_textBox.Text) && Current.Count > 0) { - Remove(Current[^1]); + ByUser(() => Remove(Current[^1])); e.Handled = true; } }, RoutingStrategies.Tunnel); @@ -124,6 +128,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Add(string tag) { var text = tag.Trim(); @@ -141,7 +153,9 @@ public void Remove(string tag) if (next.Remove(tag)) Tags = next; } - private void AddFromText() + private void AddFromText() => ByUser(AddTypedText); + + private void AddTypedText() { var text = _textBox.Text ?? string.Empty; _textBox.Text = string.Empty; @@ -159,7 +173,7 @@ private void OnTagsReplaced(object? oldValue, object? newValue) private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Rebuild(); - TagsChanged?.Invoke(this, Current); + if (_byUser) TagsChanged?.Invoke(this, Current); } private void Rebuild() @@ -185,7 +199,7 @@ private Control CreateChip(string tag) remove.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Remove(tag); + if (IsEffectivelyEnabled) ByUser(() => Remove(tag)); e.Handled = true; }; diff --git a/examples/Avalonia.Demo/Components/UI/TimePicker.cs b/examples/Avalonia.Demo/Components/UI/TimePicker.cs index 26afe97..a2ff44b 100644 --- a/examples/Avalonia.Demo/Components/UI/TimePicker.cs +++ b/examples/Avalonia.Demo/Components/UI/TimePicker.cs @@ -61,7 +61,7 @@ static TimePicker() { p.UpdateText(); p.UpdateSelection(); - p.TimeChanged?.Invoke(p, EventArgs.Empty); + if (p._byUser) p.TimeChanged?.Invoke(p, EventArgs.Empty); }); MinuteStepProperty.Changed.AddClassHandler((p, _) => p.BuildColumns()); Is24HourProperty.Changed.AddClassHandler((p, _) => { p.BuildColumns(); p.UpdateText(); }); @@ -95,8 +95,12 @@ public string? Format public bool IsOpen => _popup.IsOpen; + // User input only: setting Time from code or a binding doesn't raise it. public event EventHandler? TimeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public TimePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; @@ -250,12 +254,20 @@ private ScrollViewer AddColumn(List cells, IEnumerable values, Func Time.Hours >= 12; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void SetHour(int hour) => - Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0); + ByUser(() => Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0)); - private void SetMinute(int minute) => Time = new TimeSpan(Time.Hours, minute, 0); + private void SetMinute(int minute) => ByUser(() => Time = new TimeSpan(Time.Hours, minute, 0)); - private void SetPeriod(int period) => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0); + private void SetPeriod(int period) => ByUser(() => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0)); private void UpdateSelection() { diff --git a/examples/Avalonia.Demo/Components/UI/Toggle.cs b/examples/Avalonia.Demo/Components/UI/Toggle.cs index 4f30b73..1c3e826 100644 --- a/examples/Avalonia.Demo/Components/UI/Toggle.cs +++ b/examples/Avalonia.Demo/Components/UI/Toggle.cs @@ -37,7 +37,7 @@ static Toggle() IsPressedProperty.Changed.AddClassHandler((t, _) => { t.UpdateVisualState(); - t.PressedChanged?.Invoke(t, t.IsPressed); + if (t._byUser) t.PressedChanged?.Invoke(t, t.IsPressed); }); TextProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); IconProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); @@ -76,8 +76,12 @@ public ToggleSize Size set => SetValue(SizeProperty, value); } + // User input only: setting IsPressed from code or a binding doesn't raise it. public event EventHandler? PressedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Toggle() { _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; @@ -109,11 +113,19 @@ protected override void OnPointerExited(PointerEventArgs e) UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsPressed = !IsPressed; + ByUser(() => IsPressed = !IsPressed); e.Handled = true; } @@ -121,7 +133,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return; - IsPressed = !IsPressed; + ByUser(() => IsPressed = !IsPressed); e.Handled = true; } diff --git a/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs index 5977b14..bd055df 100644 --- a/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs +++ b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs @@ -44,7 +44,7 @@ static ToggleGroup() ValueProperty.Changed.AddClassHandler((g, _) => { g.Refresh(); - g.ValueChanged?.Invoke(g, g.Value); + if (g._byUser) g.ValueChanged?.Invoke(g, g.Value); }); ValuesProperty.Changed.AddClassHandler((g, e) => g.OnValuesReplaced(e.OldValue, e.NewValue)); } @@ -76,9 +76,13 @@ public ToggleGroupVariant Variant [Content] public Controls Items => _row.Children; + // User input only: setting Value or Values from code or a binding doesn't raise them. public event EventHandler? ValueChanged; public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public ToggleGroup() { _row.Children.CollectionChanged += (_, _) => Refresh(); @@ -89,7 +93,18 @@ public ToggleGroup() public bool IsPressed(string? value) => value != null && (Multiple ? Values?.Contains(value) == true : Value == value); - internal void Toggle(string? value) + // A click or key on an item. + internal void Toggle(string? value) => ByUser(() => ToggleValue(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + + private void ToggleValue(string? value) { if (value is null || !IsEffectivelyEnabled) return; if (Multiple) @@ -115,7 +130,7 @@ private void OnValuesReplaced(object? oldValue, object? newValue) private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Refresh(); - ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + if (_byUser) ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); } // Items don't track the group's state: the group pushes it to them. diff --git a/examples/Avalonia.Demo/Components/UI/TreeView.cs b/examples/Avalonia.Demo/Components/UI/TreeView.cs index 299a19a..b08f371 100644 --- a/examples/Avalonia.Demo/Components/UI/TreeView.cs +++ b/examples/Avalonia.Demo/Components/UI/TreeView.cs @@ -31,7 +31,7 @@ static TreeView() SelectedValueProperty.Changed.AddClassHandler((t, _) => { t.Refresh(); - t.SelectedValueChanged?.Invoke(t, t.SelectedValue); + if (t._byUser) t.SelectedValueChanged?.Invoke(t, t.SelectedValue); }); } @@ -44,8 +44,23 @@ public string? SelectedValue [Content] public Controls Items => _stack.Children; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + // A click or key on a row. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public TreeView() { Child = _stack; @@ -182,7 +197,7 @@ private void Press() { if (!IsEffectivelyEnabled) return; if (HasChildren) IsExpanded = !IsExpanded; - if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + if (this.FindParentOfType() is { } tree) tree.SelectByUser(EffectiveValue); } internal void ApplySelection(string? selected) diff --git a/examples/MAUI.Demo/Components/UI/Accordion.cs b/examples/MAUI.Demo/Components/UI/Accordion.cs index 4a16feb..bd8d98d 100644 --- a/examples/MAUI.Demo/Components/UI/Accordion.cs +++ b/examples/MAUI.Demo/Components/UI/Accordion.cs @@ -32,8 +32,12 @@ public string Value } // Fired when any item's open state changes: (value, isOpen). + // User input only: setting Value, or calling Toggle, from code doesn't raise it. public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly HashSet _openValues = new(); public Accordion() @@ -44,6 +48,20 @@ public Accordion() public bool IsOpen(string value) => _openValues.Contains(value); + // Read by items, whose OpenChanged follows the same rule. + internal bool IsUserChange => _byUser; + + // A click or key on a trigger. + internal void ToggleByUser(string value) => ByUser(() => Toggle(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string value) { var wasOpen = _openValues.Contains(value); @@ -63,7 +81,7 @@ public void Toggle(string value) changed.Add((value, !wasOpen)); Refresh(animate: true); - foreach (var change in changed) ItemToggled?.Invoke(this, change); + if (_byUser) foreach (var change in changed) ItemToggled?.Invoke(this, change); } private void OnValueChanged(string? value) diff --git a/examples/MAUI.Demo/Components/UI/AccordionItem.cs b/examples/MAUI.Demo/Components/UI/AccordionItem.cs index 7e76c09..beced9e 100644 --- a/examples/MAUI.Demo/Components/UI/AccordionItem.cs +++ b/examples/MAUI.Demo/Components/UI/AccordionItem.cs @@ -14,6 +14,7 @@ public string Value } public bool IsOpen { get; private set; } + // User input only, like the accordion's ItemToggled. public event EventHandler? OpenChanged; private readonly VerticalStackLayout _stack; @@ -31,6 +32,8 @@ public AccordionItem() public void Toggle() => this.FindParentOfType()?.Toggle(Value); + internal void ToggleByUser() => this.FindParentOfType()?.ToggleByUser(Value); + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; internal void ApplyOpen(bool open, bool animate) @@ -41,6 +44,6 @@ internal void ApplyOpen(bool open, bool animate) trigger.SetOpen(open, animate); foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) content.Apply(open, animate); - if (changed) OpenChanged?.Invoke(this, open); + if (changed && this.FindParentOfType() is { IsUserChange: true }) OpenChanged?.Invoke(this, open); } } diff --git a/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs index e723f91..d254b7b 100644 --- a/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs +++ b/examples/MAUI.Demo/Components/UI/AccordionTrigger.cs @@ -54,7 +54,7 @@ public AccordionTrigger() Content = _row; } - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); internal void SetOpen(bool open, bool animate) { diff --git a/examples/MAUI.Demo/Components/UI/Carousel.cs b/examples/MAUI.Demo/Components/UI/Carousel.cs index ad354e1..de6daed 100644 --- a/examples/MAUI.Demo/Components/UI/Carousel.cs +++ b/examples/MAUI.Demo/Components/UI/Carousel.cs @@ -86,8 +86,13 @@ public double AspectRatio public IList Slides => _track.Children; + // User input only (arrows, dots, swipes, keys): auto-play, Next / Previous and setting Position + // from code don't raise it. public event EventHandler? PositionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const string SlideAnimation = "CarouselSlide"; private readonly Grid _track; @@ -117,8 +122,8 @@ public Carousel() BackgroundColor = Colors.Transparent }; - _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, "Previous slide", Previous); - _next = Arrow(IconName.ChevronRight, LayoutOptions.End, "Next slide", Next); + _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, "Previous slide", () => ByUser(Previous)); + _next = Arrow(IconName.ChevronRight, LayoutOptions.End, "Next slide", () => ByUser(Next)); _dots = new HorizontalStackLayout { Spacing = 0, @@ -137,6 +142,14 @@ public Carousel() private int Count => _track.Children.Count; private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1)); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (Current < Count - 1) Position = Current + 1; @@ -191,7 +204,7 @@ private void OnSlidesChanged() // 8px dot in a 16px tap target. var target = new Grid { WidthRequest = 16, HeightRequest = 16, BackgroundColor = Colors.Transparent, Children = { dot } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => Position = index; + tap.Tapped += (_, _) => ByUser(() => Position = index); target.GestureRecognizers.Add(tap); _dots.Children.Add(target); } @@ -206,7 +219,7 @@ private void OnPositionChanged() this.AbortAnimation(SlideAnimation); new Animation(v => { _offset = v; Render(); }, _offset, Current) .Commit(this, SlideAnimation, 16, 300, Easing.CubicOut); - PositionChanged?.Invoke(this, Current); + if (_byUser) PositionChanged?.Invoke(this, Current); // A manual move restarts the auto-play countdown. UpdateTimer(); } @@ -268,8 +281,8 @@ private void OnPan(object? sender, PanUpdatedEventArgs e) var moved = _dragX / width; _dragX = 0; var current = Current; - if (moved <= -0.2 && current < Count - 1) Position = current + 1; - else if (moved >= 0.2 && current > 0) Position = current - 1; + if (moved <= -0.2 && current < Count - 1) ByUser(() => Position = current + 1); + else if (moved >= 0.2 && current > 0) ByUser(() => Position = current - 1); else { // Not far enough: settle back. diff --git a/examples/MAUI.Demo/Components/UI/Checkbox.cs b/examples/MAUI.Demo/Components/UI/Checkbox.cs index 26b1a2e..dffc779 100644 --- a/examples/MAUI.Demo/Components/UI/Checkbox.cs +++ b/examples/MAUI.Demo/Components/UI/Checkbox.cs @@ -40,8 +40,12 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting IsChecked from code or a binding doesn't raise it. public event EventHandler? CheckedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Checkbox() { _check = new Icon { Name = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground }; @@ -67,20 +71,28 @@ public Checkbox() }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); IsChecked = !IsChecked; }; + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); ByUser(() => IsChecked = !IsChecked); }; row.GestureRecognizers.Add(tap); - ShellFocus.MakeFocusable(this, () => { if (IsEnabled) IsChecked = !IsChecked; }); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) ByUser(() => IsChecked = !IsChecked); }); Content = row; HorizontalOptions = LayoutOptions.Start; UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnIsCheckedChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is not Checkbox checkbox) return; checkbox.UpdateVisualState(); - checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); + if (checkbox._byUser) checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); } protected override void OnPropertyChanged(string? propertyName = null) diff --git a/examples/MAUI.Demo/Components/UI/Collapsible.cs b/examples/MAUI.Demo/Components/UI/Collapsible.cs index eaf3c57..8083118 100644 --- a/examples/MAUI.Demo/Components/UI/Collapsible.cs +++ b/examples/MAUI.Demo/Components/UI/Collapsible.cs @@ -17,8 +17,12 @@ public bool Open set => SetValue(OpenProperty, value); } + // User input only: setting Open, or calling Toggle / SetOpen, from code doesn't raise it. public event EventHandler? OpenChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Collapsible() { Spacing = 8; @@ -29,10 +33,21 @@ public Collapsible() public void Toggle() => Open = !Open; + // A click or key on the trigger. + internal void ToggleByUser() => ByUser(Toggle); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnOpenChanged() { Refresh(animate: true); - OpenChanged?.Invoke(this, Open); + if (_byUser) OpenChanged?.Invoke(this, Open); } // The collapsible drives its content, so state never depends on when a child found its parent. diff --git a/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs index fa53dee..fb77378 100644 --- a/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs +++ b/examples/MAUI.Demo/Components/UI/CollapsibleTrigger.cs @@ -3,5 +3,5 @@ namespace MAUI.Demo.Components.UI; // Toggles the enclosing Collapsible. Wrap a Button or any view (a row, an icon, a label). public partial class CollapsibleTrigger : ShellTriggerView { - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); } diff --git a/examples/MAUI.Demo/Components/UI/Combobox.cs b/examples/MAUI.Demo/Components/UI/Combobox.cs index 3f79c9c..87a3dcf 100644 --- a/examples/MAUI.Demo/Components/UI/Combobox.cs +++ b/examples/MAUI.Demo/Components/UI/Combobox.cs @@ -60,8 +60,12 @@ public string EmptyText public bool IsOpen { get; private set; } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemHeight = 32; private const double MaxListHeight = 240; @@ -198,9 +202,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void Pick(string text) { - Value = text; + ByUser(() => Value = text); SetOpen(false); } @@ -208,7 +220,7 @@ private void OnValueChanged() { UpdateTrigger(); UpdateChecks(); - ValueChanged?.Invoke(this, Value ?? string.Empty); + if (_byUser) ValueChanged?.Invoke(this, Value ?? string.Empty); } private void UpdateTrigger() diff --git a/examples/MAUI.Demo/Components/UI/DataTable.cs b/examples/MAUI.Demo/Components/UI/DataTable.cs index 4c7be94..9f6b20b 100644 --- a/examples/MAUI.Demo/Components/UI/DataTable.cs +++ b/examples/MAUI.Demo/Components/UI/DataTable.cs @@ -119,8 +119,12 @@ public string Filter set => _filter.Text = value; } + // User input only: ClearSelection and changes from code don't raise it. public event EventHandler>? SelectionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + // Raised with the row's item. public event EventHandler? RowTapped; @@ -211,6 +215,14 @@ public void SortBy(DataTableColumn? column, bool descending = false) Refresh(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void ClearSelection() { if (_selected.Count == 0) return; @@ -250,7 +262,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.UnionWith(_view); else _selected.ExceptWith(_view); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; header.Children.Add(all); _allCheck = all; @@ -272,7 +284,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.Add(item); else _selected.Remove(item); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; row.Children.Add(check); } @@ -320,7 +332,7 @@ private void ShowSelectionState() private void OnSelectionChanged() { ShowSelectionState(); - SelectionChanged?.Invoke(this, SelectedItems); + if (_byUser) SelectionChanged?.Invoke(this, SelectedItems); } // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction diff --git a/examples/MAUI.Demo/Components/UI/DatePicker.cs b/examples/MAUI.Demo/Components/UI/DatePicker.cs index 145a18c..d260912 100644 --- a/examples/MAUI.Demo/Components/UI/DatePicker.cs +++ b/examples/MAUI.Demo/Components/UI/DatePicker.cs @@ -51,8 +51,20 @@ public string Format public bool IsOpen { get; private set; } + // User input only: setting Date from code or a binding doesn't raise it. public event EventHandler? DateChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private readonly Border _trigger; private readonly Label _text; private readonly Calendar _calendar; @@ -87,7 +99,7 @@ public DatePicker() _calendar = new Calendar { SelectedDate = Date }; _calendar.DateSelected += (_, date) => { - Date = date; + ByUser(() => Date = date); SetOpen(false); }; @@ -135,7 +147,7 @@ private static void OnDateChanged(BindableObject b, object o, object n) var picker = (DatePicker)b; picker.UpdateText(); picker._calendar.SelectedDate = (DateTime)n; - picker.DateChanged?.Invoke(picker, new DateChangedEventArgs((DateTime)o, (DateTime)n)); + if (picker._byUser) picker.DateChanged?.Invoke(picker, new DateChangedEventArgs((DateTime)o, (DateTime)n)); } private void UpdateText() => _text.Text = Date.ToString(Format, CultureInfo.CurrentCulture); diff --git a/examples/MAUI.Demo/Components/UI/DateRangePicker.cs b/examples/MAUI.Demo/Components/UI/DateRangePicker.cs index 2735eb5..410e180 100644 --- a/examples/MAUI.Demo/Components/UI/DateRangePicker.cs +++ b/examples/MAUI.Demo/Components/UI/DateRangePicker.cs @@ -84,8 +84,12 @@ public DateTime? MaximumDate public bool IsOpen { get; private set; } // Raised when StartDate or EndDate changes. + // User input only: setting StartDate / EndDate, or calling Clear, from code doesn't raise it. public event EventHandler? RangeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _trigger; private readonly Label _text; private readonly Grid _clear; @@ -113,7 +117,7 @@ public DateRangePicker() _clearTapped = true; // Cleared on the next turn in case the tap never reaches the trigger. Dispatcher.Dispatch(() => _clearTapped = false); - Clear(); + ByUser(Clear); }; _clear.GestureRecognizers.Add(clearTap); @@ -153,7 +157,7 @@ public DateRangePicker() ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); }); _calendar = new Calendar(); - _calendar.DateSelected += (_, date) => Pick(date); + _calendar.DateSelected += (_, date) => ByUser(() => Pick(date)); _panel = new Border { @@ -194,6 +198,14 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Clear() { StartDate = null; @@ -218,7 +230,7 @@ private void OnRangeChanged() { UpdateTrigger(); UpdateCalendar(); - RangeChanged?.Invoke(this, EventArgs.Empty); + if (_byUser) RangeChanged?.Invoke(this, EventArgs.Empty); } private void UpdateCalendar() diff --git a/examples/MAUI.Demo/Components/UI/InputOtp.cs b/examples/MAUI.Demo/Components/UI/InputOtp.cs index 46534fe..e4916c4 100644 --- a/examples/MAUI.Demo/Components/UI/InputOtp.cs +++ b/examples/MAUI.Demo/Components/UI/InputOtp.cs @@ -49,10 +49,14 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting Value from code or a binding doesn't raise ValueChanged or Completed. public event EventHandler? ValueChanged; // Raised when every slot is filled. public event EventHandler? Completed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double SlotSize = 40; private readonly Entry _entry; @@ -80,7 +84,7 @@ public InputOtp() ShellPlatform.StripNativeChrome(_entry); ShellPlatform.HideCaret(_entry); ShellFocus.Track(_entry); - _entry.TextChanged += (_, e) => Value = Sanitize(e.NewTextValue); + _entry.TextChanged += (_, e) => ByUser(() => Value = Sanitize(e.NewTextValue)); _entry.Focused += (_, _) => { _focused = true; UpdateSlots(); }; _entry.Unfocused += (_, _) => { _focused = false; UpdateSlots(); }; @@ -140,12 +144,21 @@ private void BuildSlots() OnValueChanged(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged() { var value = Sanitize(Value); if (value != Value) { Value = value; return; } if (_entry.Text != value) _entry.Text = value; UpdateSlots(); + if (!_byUser) return; ValueChanged?.Invoke(this, value); if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); } diff --git a/examples/MAUI.Demo/Components/UI/MultiSelect.cs b/examples/MAUI.Demo/Components/UI/MultiSelect.cs index 6163bd5..cd8b946 100644 --- a/examples/MAUI.Demo/Components/UI/MultiSelect.cs +++ b/examples/MAUI.Demo/Components/UI/MultiSelect.cs @@ -62,8 +62,12 @@ public string EmptyText public bool IsOpen { get; private set; } + // User input only: setting Values, or calling Toggle, from code doesn't raise it. public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemHeight = 32; private const double MaxListHeight = 240; @@ -134,7 +138,7 @@ public MultiSelect() _search.Completed += (_, _) => { var first = _rows.FirstOrDefault(r => r.Row.IsVisible); - if (first.Row != null) Toggle(first.Text); + if (first.Row != null) ToggleByUser(first.Text); }; var searchRow = new Grid @@ -215,6 +219,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); // Adds the option when it isn't picked, removes it when it is. + // A click, a tap or Enter on a row, or the × on a chip. + private void ToggleByUser(string text) => ByUser(() => Toggle(text)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string text) { // A new list each time, so a two-way binding sees the change. @@ -234,7 +249,7 @@ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e { RebuildChips(); UpdateChecks(); - ValuesChanged?.Invoke(this, Current); + if (_byUser) ValuesChanged?.Invoke(this, Current); } private void RebuildChips() @@ -265,7 +280,7 @@ private View CreateChip(string value) _chipTapped = true; // Cleared on the next turn in case the tap never reaches the trigger. Dispatcher.Dispatch(() => _chipTapped = false); - Toggle(value); + ToggleByUser(value); }; remove.GestureRecognizers.Add(tap); @@ -323,7 +338,7 @@ private View CreateItem(string text) pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; }; item.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => Toggle(text); + tap.Tapped += (_, _) => ToggleByUser(text); item.GestureRecognizers.Add(tap); _rows.Add((text, item, check)); diff --git a/examples/MAUI.Demo/Components/UI/Navbar.cs b/examples/MAUI.Demo/Components/UI/Navbar.cs index 901581c..3bde823 100644 --- a/examples/MAUI.Demo/Components/UI/Navbar.cs +++ b/examples/MAUI.Demo/Components/UI/Navbar.cs @@ -92,8 +92,12 @@ public bool IsMenuOpen public IList Items => _items; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly ObservableCollection _items = new(); private readonly Icon _brandIcon; private readonly Label _title; @@ -165,16 +169,24 @@ public Navbar() UpdateBrand(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + internal void Select(NavbarItem item) { - if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + if (!string.IsNullOrEmpty(item.Value)) ByUser(() => SelectedValue = item.Value); IsMenuOpen = false; } private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } private void UpdateBrand() diff --git a/examples/MAUI.Demo/Components/UI/NumberInput.cs b/examples/MAUI.Demo/Components/UI/NumberInput.cs index 549e288..c0ebedd 100644 --- a/examples/MAUI.Demo/Components/UI/NumberInput.cs +++ b/examples/MAUI.Demo/Components/UI/NumberInput.cs @@ -58,8 +58,12 @@ public string Placeholder set => SetValue(PlaceholderProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _frame; private readonly Entry _entry; private readonly Grid _decrease; @@ -140,12 +144,20 @@ private Grid StepButton(IconName icon, string description, int direction) { if (!IsEnabled) return; Commit(); - Value = (Value ?? Minimum ?? 0) + direction * Step; + ByUser(() => Value = (Value ?? Minimum ?? 0) + direction * Step); }; button.GestureRecognizers.Add(tap); return button; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private decimal? Clamp(decimal? value) { if (value is not { } number) return null; @@ -155,7 +167,9 @@ private Grid StepButton(IconName icon, string description, int direction) } // A typed number is clamped and kept, empty clears the value, anything else is reverted. - private void Commit() + private void Commit() => ByUser(CommitText); + + private void CommitText() { var text = _entry.Text?.Trim(); if (string.IsNullOrEmpty(text)) Value = null; @@ -175,7 +189,7 @@ private void OnValueChanged() { UpdateText(); UpdateButtons(); - ValueChanged?.Invoke(this, Value); + if (_byUser) ValueChanged?.Invoke(this, Value); } private void UpdateText() diff --git a/examples/MAUI.Demo/Components/UI/Pagination.cs b/examples/MAUI.Demo/Components/UI/Pagination.cs index 012261e..f7453fd 100644 --- a/examples/MAUI.Demo/Components/UI/Pagination.cs +++ b/examples/MAUI.Demo/Components/UI/Pagination.cs @@ -49,8 +49,12 @@ public bool ShowLabels set => SetValue(ShowLabelsProperty, value); } + // User input only: setting Page from code or a binding doesn't raise it. public event EventHandler? PageChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemSize = 36; private readonly HorizontalStackLayout _row; @@ -66,7 +70,18 @@ public Pagination() private void OnPageChanged() { Rebuild(); - PageChanged?.Invoke(this, Page); + if (_byUser) PageChanged?.Invoke(this, Page); + } + + // A click or key on a page or an arrow. + private void GoTo(int page) => ByUser(() => Page = page); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } // Page is not clamped when set (XAML may set Page before TotalPages), only when shown. @@ -93,10 +108,10 @@ private List VisiblePages() private void Rebuild() { _row.Children.Clear(); - _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => Page = Current - 1)); + _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => GoTo(Current - 1))); foreach (var page in VisiblePages()) _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page)); - _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => Page = Current + 1)); + _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => GoTo(Current + 1))); } private View PageItem(int page) @@ -110,7 +125,7 @@ private View PageItem(int page) VerticalTextAlignment = TextAlignment.Center }; label.Token(Label.TextColorProperty, ShellToken.Foreground); - var item = Item(label, ItemSize, new Thickness(0), () => Page = page); + var item = Item(label, ItemSize, new Thickness(0), () => GoTo(page)); item.MinimumWidthRequest = ItemSize; if (current) { diff --git a/examples/MAUI.Demo/Components/UI/RadioGroup.cs b/examples/MAUI.Demo/Components/UI/RadioGroup.cs index 3889d08..a9df62e 100644 --- a/examples/MAUI.Demo/Components/UI/RadioGroup.cs +++ b/examples/MAUI.Demo/Components/UI/RadioGroup.cs @@ -15,8 +15,12 @@ public string Value set => SetValue(ValueProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly VerticalStackLayout _stack; public new IList Children => _stack.Children; @@ -30,11 +34,22 @@ public RadioGroup() public void SetValue(string value) => Value = value; + // A click or key on an item. + internal void SelectByUser(string value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnValueChanged(BindableObject b, object o, object n) { if (b is not RadioGroup group) return; group.RefreshItems(); - group.ValueChanged?.Invoke(group, n as string ?? string.Empty); + if (group._byUser) group.ValueChanged?.Invoke(group, n as string ?? string.Empty); } // The group drives its items, so item state never depends on when an item found its parent. diff --git a/examples/MAUI.Demo/Components/UI/RadioGroupItem.cs b/examples/MAUI.Demo/Components/UI/RadioGroupItem.cs index 7041c75..8674b8d 100644 --- a/examples/MAUI.Demo/Components/UI/RadioGroupItem.cs +++ b/examples/MAUI.Demo/Components/UI/RadioGroupItem.cs @@ -68,7 +68,7 @@ public RadioGroupItem() HorizontalOptions = LayoutOptions.Start; } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetChecked(bool isChecked) { diff --git a/examples/MAUI.Demo/Components/UI/Select.cs b/examples/MAUI.Demo/Components/UI/Select.cs index fa1cd04..78ab06f 100644 --- a/examples/MAUI.Demo/Components/UI/Select.cs +++ b/examples/MAUI.Demo/Components/UI/Select.cs @@ -42,8 +42,12 @@ public string Placeholder public bool IsOpen { get; private set; } + // User input only: setting SelectedIndex from code or a binding doesn't raise it. public event EventHandler? SelectedIndexChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private ShellPopupHandle? _handle; private readonly Border _trigger; private readonly Label _value; @@ -124,12 +128,20 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnSelectedIndexChanged(BindableObject b, object o, object n) { var select = (Select)b; select.UpdateTrigger(); select.UpdateItemStates(); - select.SelectedIndexChanged?.Invoke(select, EventArgs.Empty); + if (select._byUser) select.SelectedIndexChanged?.Invoke(select, EventArgs.Empty); } private void UpdateTrigger() @@ -180,7 +192,7 @@ private View CreateItem(string text, int index) pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; }; item.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { SelectedIndex = index; SetOpen(false); }; + tap.Tapped += (_, _) => { ByUser(() => SelectedIndex = index); SetOpen(false); }; item.GestureRecognizers.Add(tap); return item; } diff --git a/examples/MAUI.Demo/Components/UI/Sidebar.cs b/examples/MAUI.Demo/Components/UI/Sidebar.cs index 512a79d..4d451e3 100644 --- a/examples/MAUI.Demo/Components/UI/Sidebar.cs +++ b/examples/MAUI.Demo/Components/UI/Sidebar.cs @@ -92,8 +92,12 @@ public View? Footer public IList Groups => _groups.Children; public event EventHandler? IsCollapsedChanged; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const string WidthAnimation = "SidebarWidth"; private readonly ContentView _header; @@ -129,6 +133,17 @@ public Sidebar() public void Toggle() => IsCollapsed = !IsCollapsed; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnCollapsedChanged() { Refresh(); @@ -139,7 +154,7 @@ private void OnCollapsedChanged() private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } private void ApplyWidth(bool animate) @@ -326,7 +341,7 @@ public SidebarItem() private void Press() { if (!IsEnabled) return; - if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectByUser(Value); Clicked?.Invoke(this, EventArgs.Empty); } diff --git a/examples/MAUI.Demo/Components/UI/Slider.cs b/examples/MAUI.Demo/Components/UI/Slider.cs index 24956dc..8ac40c2 100644 --- a/examples/MAUI.Demo/Components/UI/Slider.cs +++ b/examples/MAUI.Demo/Components/UI/Slider.cs @@ -35,8 +35,13 @@ public double Maximum set => SetValue(MaximumProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while Value, Minimum or Maximum is pushed into the platform slider, which reports + // those changes like a drag. + private bool _syncing; + public Slider() { _native = new Microsoft.Maui.Controls.Slider { Minimum = 0, Maximum = 100, Value = 50 }; @@ -46,22 +51,28 @@ public Slider() _native.ValueChanged += (s, e) => { Value = e.NewValue; - ValueChanged?.Invoke(this, e); + if (!_syncing) ValueChanged?.Invoke(this, e); }; Content = _native; } private void SyncRange() { + var syncing = _syncing; + _syncing = true; // Widen before narrowing so Minimum never exceeds Maximum mid-update. if (Maximum > _native.Maximum) { _native.Maximum = Maximum; _native.Minimum = Minimum; } else { _native.Minimum = Minimum; _native.Maximum = Maximum; } SyncValue(); + _syncing = syncing; } private void SyncValue() { - if (Math.Abs(_native.Value - Value) > 0.001) - _native.Value = Value; + if (Math.Abs(_native.Value - Value) <= 0.001) return; + var syncing = _syncing; + _syncing = true; + _native.Value = Value; + _syncing = syncing; } } diff --git a/examples/MAUI.Demo/Components/UI/Stepper.cs b/examples/MAUI.Demo/Components/UI/Stepper.cs index d1512e6..2127fa1 100644 --- a/examples/MAUI.Demo/Components/UI/Stepper.cs +++ b/examples/MAUI.Demo/Components/UI/Stepper.cs @@ -67,10 +67,14 @@ public string ConfirmText public IList Steps => _steps; + // User input only: setting CurrentStep, or calling Next / Previous, from code doesn't raise it. public event EventHandler? CurrentStepChanged; // Raised by the Confirm button on the last step. public event EventHandler? Confirmed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double CircleSize = 32; private sealed class Marker(Border circle, Label number, Icon check, Label title, BoxView before, BoxView after) @@ -98,7 +102,7 @@ public Stepper() _body = new ContentView { Margin = new Thickness(0, 24, 0, 0) }; _previous = new Button { Variant = ButtonVariant.Outline, HorizontalOptions = LayoutOptions.Start }; - _previous.Clicked += (_, _) => Previous(); + _previous.Clicked += (_, _) => ByUser(Previous); _next = new Button { HorizontalOptions = LayoutOptions.End }; _next.Clicked += (_, _) => { @@ -107,7 +111,7 @@ public Stepper() _highest = Math.Max(_highest, _steps.Count - 1); Confirmed?.Invoke(this, EventArgs.Empty); } - else Next(); + else ByUser(Next); }; _navigation = new Grid { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; @@ -119,6 +123,14 @@ public Stepper() private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, _steps.Count - 1)); private bool IsLast => Current >= _steps.Count - 1; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (!IsLast) CurrentStep = Current + 1; @@ -133,7 +145,7 @@ private void OnCurrentStepChanged() { _highest = Math.Max(_highest, Current); UpdateState(); - CurrentStepChanged?.Invoke(this, Current); + if (_byUser) CurrentStepChanged?.Invoke(this, Current); } private void Rebuild() @@ -167,7 +179,7 @@ private void Rebuild() StrokeShape = new RoundRectangle { CornerRadius = CircleSize / 2 } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (index <= _highest) CurrentStep = index; }; + tap.Tapped += (_, _) => { if (index <= _highest) ByUser(() => CurrentStep = index); }; circle.GestureRecognizers.Add(tap); // Lines run from each circle to the edges of its column, so neighbors meet. diff --git a/examples/MAUI.Demo/Components/UI/Switch.cs b/examples/MAUI.Demo/Components/UI/Switch.cs index 4689524..cfaa1c0 100644 --- a/examples/MAUI.Demo/Components/UI/Switch.cs +++ b/examples/MAUI.Demo/Components/UI/Switch.cs @@ -36,8 +36,12 @@ public string Label set => SetValue(LabelProperty, value); } + // User input only: setting IsToggled from code or a binding doesn't raise it. public event EventHandler? Toggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Switch() { _thumb = new Border @@ -68,20 +72,28 @@ public Switch() var row = new HorizontalStackLayout { Spacing = 8, Children = { _track, _label } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); IsToggled = !IsToggled; }; + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); ByUser(() => IsToggled = !IsToggled); }; row.GestureRecognizers.Add(tap); - ShellFocus.MakeFocusable(this, () => { if (IsEnabled) IsToggled = !IsToggled; }); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) ByUser(() => IsToggled = !IsToggled); }); Content = row; HorizontalOptions = LayoutOptions.Start; UpdateVisualState(animate: false); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnIsToggledChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is not Switch control) return; control.UpdateVisualState(animate: true); - control.Toggled?.Invoke(control, (bool)newValue); + if (control._byUser) control.Toggled?.Invoke(control, (bool)newValue); } protected override void OnPropertyChanged(string? propertyName = null) diff --git a/examples/MAUI.Demo/Components/UI/Tabs.cs b/examples/MAUI.Demo/Components/UI/Tabs.cs index b511301..2cdac42 100644 --- a/examples/MAUI.Demo/Components/UI/Tabs.cs +++ b/examples/MAUI.Demo/Components/UI/Tabs.cs @@ -23,8 +23,12 @@ public string Value } // (oldValue, newValue) + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler<(string Old, string New)>? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Tabs() { Spacing = 8; @@ -33,10 +37,21 @@ public Tabs() public void SetValue(string value) => Value = value; + // A click or key on a trigger. + internal void SelectByUser(string value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged(string oldValue, string newValue) { Refresh(animate: true); - ValueChanged?.Invoke(this, (oldValue, newValue)); + if (_byUser) ValueChanged?.Invoke(this, (oldValue, newValue)); } // Tabs drives its triggers and panels, so state never depends on when a child found its parent. diff --git a/examples/MAUI.Demo/Components/UI/TabsTrigger.cs b/examples/MAUI.Demo/Components/UI/TabsTrigger.cs index 267ae0d..eecb622 100644 --- a/examples/MAUI.Demo/Components/UI/TabsTrigger.cs +++ b/examples/MAUI.Demo/Components/UI/TabsTrigger.cs @@ -58,7 +58,7 @@ public TabsTrigger() SetActive(false); } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetActive(bool active) { diff --git a/examples/MAUI.Demo/Components/UI/TagInput.cs b/examples/MAUI.Demo/Components/UI/TagInput.cs index dc69d10..dc241aa 100644 --- a/examples/MAUI.Demo/Components/UI/TagInput.cs +++ b/examples/MAUI.Demo/Components/UI/TagInput.cs @@ -48,8 +48,12 @@ public bool AllowDuplicates set => SetValue(AllowDuplicatesProperty, value); } + // User input only: setting Tags, or calling Add / Remove, from code doesn't raise it. public event EventHandler>? TagsChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private static readonly char[] Separators = { ',', ';', '\n', '\t' }; private readonly Border _frame; @@ -108,6 +112,14 @@ public TagInput() private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Add(string tag) { var text = tag.Trim(); @@ -142,7 +154,9 @@ private void KeepTyping() }); } - private void AddFromEntry() + private void AddFromEntry() => ByUser(AddEntryText); + + private void AddEntryText() { var text = _entry.Text ?? string.Empty; _entry.Text = string.Empty; @@ -160,7 +174,7 @@ private void OnTagsReplaced(object? oldValue, object? newValue) private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Rebuild(); - TagsChanged?.Invoke(this, Current); + if (_byUser) TagsChanged?.Invoke(this, Current); } private void Rebuild() @@ -188,7 +202,7 @@ private View CreateChip(string tag) pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; remove.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (IsEnabled) Remove(tag); }; + tap.Tapped += (_, _) => { if (IsEnabled) ByUser(() => Remove(tag)); }; remove.GestureRecognizers.Add(tap); var chip = new Border diff --git a/examples/MAUI.Demo/Components/UI/TimePicker.cs b/examples/MAUI.Demo/Components/UI/TimePicker.cs index 756c60b..988014a 100644 --- a/examples/MAUI.Demo/Components/UI/TimePicker.cs +++ b/examples/MAUI.Demo/Components/UI/TimePicker.cs @@ -55,8 +55,12 @@ public string? Format public bool IsOpen { get; private set; } + // User input only: setting Time from code or a binding doesn't raise it. public event EventHandler? TimeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double CellHeight = 32; private const double CellSpacing = 2; private const double ColumnHeight = 7 * CellHeight + 6 * CellSpacing; @@ -154,7 +158,7 @@ private static void OnTimeChanged(BindableObject b, object o, object n) var picker = (TimePicker)b; picker.UpdateText(); picker.UpdateSelection(); - picker.TimeChanged?.Invoke(picker, EventArgs.Empty); + if (picker._byUser) picker.TimeChanged?.Invoke(picker, EventArgs.Empty); } private void UpdateText() @@ -241,12 +245,20 @@ private ScrollView AddColumn(List cells, IEnumerable values, Func Time.Hours >= 12; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void SetHour(int hour) => - Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0); + ByUser(() => Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0)); - private void SetMinute(int minute) => Time = new TimeSpan(Time.Hours, minute, 0); + private void SetMinute(int minute) => ByUser(() => Time = new TimeSpan(Time.Hours, minute, 0)); - private void SetPeriod(int period) => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0); + private void SetPeriod(int period) => ByUser(() => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0)); private void UpdateSelection() { diff --git a/examples/MAUI.Demo/Components/UI/Toggle.cs b/examples/MAUI.Demo/Components/UI/Toggle.cs index bcd187a..e179901 100644 --- a/examples/MAUI.Demo/Components/UI/Toggle.cs +++ b/examples/MAUI.Demo/Components/UI/Toggle.cs @@ -57,8 +57,12 @@ public ToggleSize Size set => SetValue(SizeProperty, value); } + // User input only: setting IsPressed from code or a binding doesn't raise it. public event EventHandler? PressedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _border; private readonly Icon _icon; private readonly Label _label; @@ -103,13 +107,21 @@ public Toggle() private void Press() { - if (IsEnabled) IsPressed = !IsPressed; + if (IsEnabled) ByUser(() => IsPressed = !IsPressed); + } + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } private void OnPressedChanged() { UpdateVisualState(); - PressedChanged?.Invoke(this, IsPressed); + if (_byUser) PressedChanged?.Invoke(this, IsPressed); } protected override void OnPropertyChanged(string? propertyName = null) diff --git a/examples/MAUI.Demo/Components/UI/ToggleGroup.cs b/examples/MAUI.Demo/Components/UI/ToggleGroup.cs index 9ea0201..eb100d7 100644 --- a/examples/MAUI.Demo/Components/UI/ToggleGroup.cs +++ b/examples/MAUI.Demo/Components/UI/ToggleGroup.cs @@ -56,9 +56,13 @@ public ToggleGroupVariant Variant public IList Items => _row.Children; + // User input only: setting Value or Values from code or a binding doesn't raise them. public event EventHandler? ValueChanged; public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly HorizontalStackLayout _row; public ToggleGroup() @@ -73,7 +77,18 @@ public ToggleGroup() public bool IsPressed(string value) => Multiple ? Values?.Contains(value) == true : Value == value; - internal void Toggle(string value) + // A click or key on an item. + internal void Toggle(string value) => ByUser(() => ToggleValue(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + + private void ToggleValue(string value) { if (!IsEnabled) return; if (Multiple) @@ -92,7 +107,7 @@ internal void Toggle(string value) private void OnValueChanged() { Refresh(); - ValueChanged?.Invoke(this, Value); + if (_byUser) ValueChanged?.Invoke(this, Value); } private void OnValuesReplaced(object? oldValue, object? newValue) @@ -105,7 +120,7 @@ private void OnValuesReplaced(object? oldValue, object? newValue) private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Refresh(); - ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + if (_byUser) ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); } // Items don't track the group's state: the group pushes it to them. diff --git a/examples/MAUI.Demo/Components/UI/TreeView.cs b/examples/MAUI.Demo/Components/UI/TreeView.cs index dd31389..89a3c55 100644 --- a/examples/MAUI.Demo/Components/UI/TreeView.cs +++ b/examples/MAUI.Demo/Components/UI/TreeView.cs @@ -26,8 +26,23 @@ public string? SelectedValue public IList Items => _stack.Children; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + // A click or key on a row. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private readonly VerticalStackLayout _stack; public TreeView() @@ -40,7 +55,7 @@ public TreeView() private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } // Items don't track the selection: the tree pushes it to every item under it. @@ -170,7 +185,7 @@ private void Press() { if (!IsEnabled) return; if (HasChildren) IsExpanded = !IsExpanded; - if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + if (this.FindParentOfType() is { } tree) tree.SelectByUser(EffectiveValue); } internal void ApplySelection(string? selected) diff --git a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs index 78adfd5..7cfe4c6 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs @@ -34,6 +34,7 @@ public string Value } public bool IsOpen { get; private set; } + // User input only, like the accordion's ItemToggled. public event EventHandler? OpenChanged; private readonly VerticalStackLayout _stack; @@ -51,6 +52,8 @@ public AccordionItem() public void Toggle() => this.FindParentOfType()?.Toggle(Value); + internal void ToggleByUser() => this.FindParentOfType()?.ToggleByUser(Value); + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; internal void ApplyOpen(bool open, bool animate) @@ -61,7 +64,7 @@ internal void ApplyOpen(bool open, bool animate) trigger.SetOpen(open, animate); foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) content.Apply(open, animate); - if (changed) OpenChanged?.Invoke(this, open); + if (changed && this.FindParentOfType() is { IsUserChange: true }) OpenChanged?.Invoke(this, open); } } ", @@ -92,6 +95,7 @@ public string? Value public bool IsOpen { get; private set; } + // User input only, like the accordion's ItemToggled. public event EventHandler? OpenChanged; public AccordionItem() @@ -102,6 +106,8 @@ public AccordionItem() public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + internal void ToggleByUser() => this.FindParentOfType()?.ToggleByUser(Value ?? string.Empty); + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; internal void ApplyOpen(bool open, bool animate) @@ -112,7 +118,7 @@ internal void ApplyOpen(bool open, bool animate) trigger.SetOpen(open, animate); foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) content.Apply(open, animate); - if (changed) OpenChanged?.Invoke(this, open); + if (changed && this.FindParentOfType() is { IsUserChange: true }) OpenChanged?.Invoke(this, open); } } " diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs index a58cfd1..7b597f1 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs @@ -54,8 +54,12 @@ public string Value } // Fired when any item's open state changes: (value, isOpen). + // User input only: setting Value, or calling Toggle, from code doesn't raise it. public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly HashSet _openValues = new(); public Accordion() @@ -66,6 +70,20 @@ public Accordion() public bool IsOpen(string value) => _openValues.Contains(value); + // Read by items, whose OpenChanged follows the same rule. + internal bool IsUserChange => _byUser; + + // A click or key on a trigger. + internal void ToggleByUser(string value) => ByUser(() => Toggle(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string value) { var wasOpen = _openValues.Contains(value); @@ -85,7 +103,7 @@ public void Toggle(string value) changed.Add((value, !wasOpen)); Refresh(animate: true); - foreach (var change in changed) ItemToggled?.Invoke(this, change); + if (_byUser) foreach (var change in changed) ItemToggled?.Invoke(this, change); } private void OnValueChanged(string? value) @@ -157,8 +175,12 @@ public string? Value } // Fired when any item's open state changes: (value, isOpen). + // User input only: setting Value, or calling Toggle, from code doesn't raise it. public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Accordion() { Children.CollectionChanged += (_, _) => Refresh(animate: false); @@ -166,6 +188,20 @@ public Accordion() public bool IsOpen(string value) => _openValues.Contains(value); + // Read by items, whose OpenChanged follows the same rule. + internal bool IsUserChange => _byUser; + + // A click or key on a trigger. + internal void ToggleByUser(string value) => ByUser(() => Toggle(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string value) { var wasOpen = _openValues.Contains(value); @@ -185,7 +221,7 @@ public void Toggle(string value) changed.Add((value, !wasOpen)); Refresh(animate: true); - foreach (var change in changed) ItemToggled?.Invoke(this, change); + if (_byUser) foreach (var change in changed) ItemToggled?.Invoke(this, change); } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs index b26b640..4754da5 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs @@ -73,7 +73,7 @@ public AccordionTrigger() Content = _row; } - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); internal void SetOpen(bool open, bool animate) { @@ -161,7 +161,7 @@ public AccordionTrigger() HorizontalAlignment = HorizontalAlignment.Stretch; } - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); protected override void OnPointerEntered(PointerEventArgs e) { diff --git a/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs index 7440c84..e87cde4 100644 --- a/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs @@ -105,8 +105,13 @@ public double AspectRatio public IList Slides => _track.Children; + // User input only (arrows, dots, swipes, keys): auto-play, Next / Previous and setting Position + // from code don't raise it. public event EventHandler? PositionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const string SlideAnimation = ""CarouselSlide""; private readonly Grid _track; @@ -136,8 +141,8 @@ public Carousel() BackgroundColor = Colors.Transparent }; - _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, ""Previous slide"", Previous); - _next = Arrow(IconName.ChevronRight, LayoutOptions.End, ""Next slide"", Next); + _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, ""Previous slide"", () => ByUser(Previous)); + _next = Arrow(IconName.ChevronRight, LayoutOptions.End, ""Next slide"", () => ByUser(Next)); _dots = new HorizontalStackLayout { Spacing = 0, @@ -156,6 +161,14 @@ public Carousel() private int Count => _track.Children.Count; private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1)); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (Current < Count - 1) Position = Current + 1; @@ -210,7 +223,7 @@ private void OnSlidesChanged() // 8px dot in a 16px tap target. var target = new Grid { WidthRequest = 16, HeightRequest = 16, BackgroundColor = Colors.Transparent, Children = { dot } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => Position = index; + tap.Tapped += (_, _) => ByUser(() => Position = index); target.GestureRecognizers.Add(tap); _dots.Children.Add(target); } @@ -225,7 +238,7 @@ private void OnPositionChanged() this.AbortAnimation(SlideAnimation); new Animation(v => { _offset = v; Render(); }, _offset, Current) .Commit(this, SlideAnimation, 16, 300, Easing.CubicOut); - PositionChanged?.Invoke(this, Current); + if (_byUser) PositionChanged?.Invoke(this, Current); // A manual move restarts the auto-play countdown. UpdateTimer(); } @@ -287,8 +300,8 @@ private void OnPan(object? sender, PanUpdatedEventArgs e) var moved = _dragX / width; _dragX = 0; var current = Current; - if (moved <= -0.2 && current < Count - 1) Position = current + 1; - else if (moved >= 0.2 && current > 0) Position = current - 1; + if (moved <= -0.2 && current < Count - 1) ByUser(() => Position = current + 1); + else if (moved >= 0.2 && current > 0) ByUser(() => Position = current - 1); else { // Not far enough: settle back. @@ -437,8 +450,13 @@ public double AspectRatio [Content] public Controls Slides => _track.Children; + // User input only (arrows, dots, swipes, keys): auto-play, Next / Previous and setting Position + // from code don't raise it. public event EventHandler? PositionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Carousel() { // Transparent, not null, so a drag can start between the slides' own content. @@ -450,8 +468,8 @@ public Carousel() _track.PointerReleased += OnTrackReleased; _track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true); - _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, ""Previous slide"", Previous); - _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, ""Next slide"", Next); + _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, ""Previous slide"", () => ByUser(Previous)); + _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, ""Next slide"", () => ByUser(Next)); foreach (var arrow in new[] { _previous, _next }) { arrow.PointerEntered += (_, _) => UpdateControls(); @@ -475,6 +493,14 @@ public Carousel() private int Count => _track.Children.Count; private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1)); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (Current < Count - 1) Position = Current + 1; @@ -514,8 +540,8 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Handled || e.Source is TextBox) return; - if (e.Key == Key.Left) Previous(); - else if (e.Key == Key.Right) Next(); + if (e.Key == Key.Left) ByUser(Previous); + else if (e.Key == Key.Right) ByUser(Next); else return; e.Handled = true; } @@ -580,7 +606,7 @@ private void OnSlidesChanged() target.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - Position = index; + ByUser(() => Position = index); e.Handled = true; }; _dots.Children.Add(target); @@ -596,7 +622,7 @@ private void OnPositionChanged() { UpdateControls(); AnimateTo(Current, 300); - PositionChanged?.Invoke(this, Current); + if (_byUser) PositionChanged?.Invoke(this, Current); // A manual move restarts the auto-play countdown. UpdateTimer(); } @@ -705,8 +731,8 @@ private void EndDrag(bool cancelled) if (!wasDragging) return; var current = Current; - if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1; - else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1; + if (!cancelled && moved <= -0.2 && current < Count - 1) ByUser(() => Position = current + 1); + else if (!cancelled && moved >= 0.2 && current > 0) ByUser(() => Position = current - 1); // Not far enough: settle back. else AnimateTo(current, 200); } diff --git a/src/ShellUI.Native.Templates/Templates/CheckboxTemplate.cs b/src/ShellUI.Native.Templates/Templates/CheckboxTemplate.cs index c5531a4..5de0715 100644 --- a/src/ShellUI.Native.Templates/Templates/CheckboxTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CheckboxTemplate.cs @@ -61,8 +61,12 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting IsChecked from code or a binding doesn't raise it. public event EventHandler? CheckedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Checkbox() { _check = new Icon { Name = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground }; @@ -88,20 +92,28 @@ public Checkbox() }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); IsChecked = !IsChecked; }; + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); ByUser(() => IsChecked = !IsChecked); }; row.GestureRecognizers.Add(tap); - ShellFocus.MakeFocusable(this, () => { if (IsEnabled) IsChecked = !IsChecked; }); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) ByUser(() => IsChecked = !IsChecked); }); Content = row; HorizontalOptions = LayoutOptions.Start; UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnIsCheckedChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is not Checkbox checkbox) return; checkbox.UpdateVisualState(); - checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); + if (checkbox._byUser) checkbox.CheckedChanged?.Invoke(checkbox, (bool)newValue); } protected override void OnPropertyChanged(string? propertyName = null) @@ -163,7 +175,7 @@ static Checkbox() IsCheckedProperty.Changed.AddClassHandler((c, e) => { c.UpdateVisualState(); - c.CheckedChanged?.Invoke(c, (bool)e.NewValue!); + if (c._byUser) c.CheckedChanged?.Invoke(c, (bool)e.NewValue!); }); LabelProperty.Changed.AddClassHandler((c, _) => c.UpdateLabel()); HasErrorProperty.Changed.AddClassHandler((c, _) => c.UpdateVisualState()); @@ -188,8 +200,12 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting IsChecked from code or a binding doesn't raise it. public event EventHandler? CheckedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Checkbox() { _check = new Icon { Kind = IconName.Check, Size = 12, StrokeWidth = 3, Token = ShellToken.PrimaryForeground }; @@ -213,11 +229,19 @@ public Checkbox() UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsChecked = !IsChecked; + ByUser(() => IsChecked = !IsChecked); e.Handled = true; } @@ -225,7 +249,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key != Key.Space) return; - IsChecked = !IsChecked; + ByUser(() => IsChecked = !IsChecked); e.Handled = true; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs index d62e109..994b732 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs @@ -39,8 +39,12 @@ public bool Open set => SetValue(OpenProperty, value); } + // User input only: setting Open, or calling Toggle / SetOpen, from code doesn't raise it. public event EventHandler? OpenChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Collapsible() { Spacing = 8; @@ -51,10 +55,21 @@ public Collapsible() public void Toggle() => Open = !Open; + // A click or key on the trigger. + internal void ToggleByUser() => ByUser(Toggle); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnOpenChanged() { Refresh(animate: true); - OpenChanged?.Invoke(this, Open); + if (_byUser) OpenChanged?.Invoke(this, Open); } // The collapsible drives its content, so state never depends on when a child found its parent. @@ -87,7 +102,7 @@ static Collapsible() OpenProperty.Changed.AddClassHandler((c, _) => { c.Refresh(animate: c.IsLoaded); - c.OpenChanged?.Invoke(c, c.Open); + if (c._byUser) c.OpenChanged?.Invoke(c, c.Open); }); } @@ -97,8 +112,12 @@ public bool Open set => SetValue(OpenProperty, value); } + // User input only: setting Open, or calling Toggle / SetOpen, from code doesn't raise it. public event EventHandler? OpenChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Collapsible() { Spacing = 8; @@ -109,6 +128,17 @@ public Collapsible() public void Toggle() => Open = !Open; + // A click or key on the trigger. + internal void ToggleByUser() => ByUser(Toggle); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs index 1015765..e2057e5 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs @@ -24,7 +24,7 @@ public static class CollapsibleTriggerTemplate // Toggles the enclosing Collapsible. Wrap a Button or any view (a row, an icon, a label). public partial class CollapsibleTrigger : ShellTriggerView { - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); } ", [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI; @@ -32,7 +32,7 @@ public partial class CollapsibleTrigger : ShellTriggerView // Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). public class CollapsibleTrigger : ShellTriggerView { - protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + protected override void OnActivated() => this.FindParentOfType()?.ToggleByUser(); } " }; diff --git a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs index 4a79d47..4ad3435 100644 --- a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs @@ -79,8 +79,12 @@ public string EmptyText public bool IsOpen { get; private set; } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemHeight = 32; private const double MaxListHeight = 240; @@ -217,9 +221,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void Pick(string text) { - Value = text; + ByUser(() => Value = text); SetOpen(false); } @@ -227,7 +239,7 @@ private void OnValueChanged() { UpdateTrigger(); UpdateChecks(); - ValueChanged?.Invoke(this, Value ?? string.Empty); + if (_byUser) ValueChanged?.Invoke(this, Value ?? string.Empty); } private void UpdateTrigger() @@ -360,7 +372,7 @@ static Combobox() { c.UpdateTrigger(); c.UpdateChecks(); - c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); + if (c._byUser) c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); }); ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems()); PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger()); @@ -401,8 +413,12 @@ public string? EmptyText public bool IsOpen => _popup.IsOpen; + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Combobox() { _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -517,9 +533,17 @@ private void OnSearchKeyDown(object? sender, KeyEventArgs e) e.Handled = true; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void Pick(string text) { - Value = text; + ByUser(() => Value = text); SetOpen(false); } diff --git a/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs index 205b542..79f021c 100644 --- a/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DataTableTemplate.cs @@ -138,8 +138,12 @@ public string Filter set => _filter.Text = value; } + // User input only: ClearSelection and changes from code don't raise it. public event EventHandler>? SelectionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + // Raised with the row's item. public event EventHandler? RowTapped; @@ -230,6 +234,14 @@ public void SortBy(DataTableColumn? column, bool descending = false) Refresh(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void ClearSelection() { if (_selected.Count == 0) return; @@ -269,7 +281,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.UnionWith(_view); else _selected.ExceptWith(_view); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; header.Children.Add(all); _allCheck = all; @@ -291,7 +303,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.Add(item); else _selected.Remove(item); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; row.Children.Add(check); } @@ -339,7 +351,7 @@ private void ShowSelectionState() private void OnSelectionChanged() { ShowSelectionState(); - SelectionChanged?.Invoke(this, SelectedItems); + if (_byUser) SelectionChanged?.Invoke(this, SelectedItems); } // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction @@ -604,8 +616,12 @@ public string Filter set => _filter.Text = value; } + // User input only: ClearSelection and changes from code don't raise it. public event EventHandler>? SelectionChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + // Raised with the row's item. public event EventHandler? RowTapped; @@ -673,6 +689,14 @@ public void SortBy(DataTableColumn? column, bool descending = false) Refresh(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void ClearSelection() { if (_selected.Count == 0) return; @@ -712,7 +736,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.UnionWith(_view); else _selected.ExceptWith(_view); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; header.Children.Add(all); _allCheck = all; @@ -739,7 +763,7 @@ private void Render(IReadOnlyList rows) if (_rendering) return; if (isChecked) _selected.Add(item); else _selected.Remove(item); - OnSelectionChanged(); + ByUser(OnSelectionChanged); }; row.Children.Add(check); } @@ -787,7 +811,7 @@ private void ShowSelectionState() private void OnSelectionChanged() { ShowSelectionState(); - SelectionChanged?.Invoke(this, SelectedItems); + if (_byUser) SelectionChanged?.Invoke(this, SelectedItems); } // h-11 px-4 font-medium text-muted-foreground; a sortable header also shows its direction diff --git a/src/ShellUI.Native.Templates/Templates/DatePickerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DatePickerTemplate.cs index e530a5c..fd8bf6f 100644 --- a/src/ShellUI.Native.Templates/Templates/DatePickerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DatePickerTemplate.cs @@ -70,8 +70,20 @@ public string Format public bool IsOpen { get; private set; } + // User input only: setting Date from code or a binding doesn't raise it. public event EventHandler? DateChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private readonly Border _trigger; private readonly Label _text; private readonly Calendar _calendar; @@ -106,7 +118,7 @@ public DatePicker() _calendar = new Calendar { SelectedDate = Date }; _calendar.DateSelected += (_, date) => { - Date = date; + ByUser(() => Date = date); SetOpen(false); }; @@ -154,7 +166,7 @@ private static void OnDateChanged(BindableObject b, object o, object n) var picker = (DatePicker)b; picker.UpdateText(); picker._calendar.SelectedDate = (DateTime)n; - picker.DateChanged?.Invoke(picker, new DateChangedEventArgs((DateTime)o, (DateTime)n)); + if (picker._byUser) picker.DateChanged?.Invoke(picker, new DateChangedEventArgs((DateTime)o, (DateTime)n)); } private void UpdateText() => _text.Text = Date.ToString(Format, CultureInfo.CurrentCulture); @@ -200,7 +212,7 @@ static DatePicker() { p.UpdateText(); p._calendar.SelectedDate = (DateTime)e.NewValue!; - p.DateChanged?.Invoke(p, (DateTime)e.NewValue!); + if (p._byUser) p.DateChanged?.Invoke(p, (DateTime)e.NewValue!); }); MinimumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MinimumDate = (DateTime?)e.NewValue); MaximumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MaximumDate = (DateTime?)e.NewValue); @@ -235,8 +247,20 @@ public string Format public bool IsOpen => _popup.IsOpen; // The new date. + // User input only: setting Date from code or a binding doesn't raise it. public event EventHandler? DateChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public DatePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; @@ -263,7 +287,7 @@ public DatePicker() _calendar = new Calendar { SelectedDate = Date }; _calendar.DateSelected += (_, date) => { - Date = date; + ByUser(() => Date = date); SetOpen(false); }; var panel = new Border diff --git a/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs index beb720c..9cd229d 100644 --- a/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DateRangePickerTemplate.cs @@ -103,8 +103,12 @@ public DateTime? MaximumDate public bool IsOpen { get; private set; } // Raised when StartDate or EndDate changes. + // User input only: setting StartDate / EndDate, or calling Clear, from code doesn't raise it. public event EventHandler? RangeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _trigger; private readonly Label _text; private readonly Grid _clear; @@ -132,7 +136,7 @@ public DateRangePicker() _clearTapped = true; // Cleared on the next turn in case the tap never reaches the trigger. Dispatcher.Dispatch(() => _clearTapped = false); - Clear(); + ByUser(Clear); }; _clear.GestureRecognizers.Add(clearTap); @@ -172,7 +176,7 @@ public DateRangePicker() ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); }); _calendar = new Calendar(); - _calendar.DateSelected += (_, date) => Pick(date); + _calendar.DateSelected += (_, date) => ByUser(() => Pick(date)); _panel = new Border { @@ -213,6 +217,14 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Clear() { StartDate = null; @@ -237,7 +249,7 @@ private void OnRangeChanged() { UpdateTrigger(); UpdateCalendar(); - RangeChanged?.Invoke(this, EventArgs.Empty); + if (_byUser) RangeChanged?.Invoke(this, EventArgs.Empty); } private void UpdateCalendar() @@ -371,8 +383,12 @@ public DateTime? MaximumDate public bool IsOpen => _popup.IsOpen; // Raised when StartDate or EndDate changes. + // User input only: setting StartDate / EndDate, or calling Clear, from code doesn't raise it. public event EventHandler? RangeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public DateRangePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -388,7 +404,7 @@ public DateRangePicker() _clear.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Clear(); + if (IsEffectivelyEnabled) ByUser(Clear); e.Handled = true; }; Grid.SetColumn(_clear, 2); @@ -418,7 +434,7 @@ public DateRangePicker() }; _calendar = new Calendar(); - _calendar.DateSelected += (_, date) => Pick(date); + _calendar.DateSelected += (_, date) => ByUser(() => Pick(date)); var panel = new Border { Child = _calendar, @@ -455,6 +471,14 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Clear() { StartDate = null; @@ -487,7 +511,7 @@ private void OnRangeChanged() { UpdateTrigger(); UpdateCalendar(); - RangeChanged?.Invoke(this, EventArgs.Empty); + if (_byUser) RangeChanged?.Invoke(this, EventArgs.Empty); } private void UpdateCalendar() diff --git a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs index 0de5335..4b914c1 100644 --- a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs @@ -68,10 +68,14 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting Value from code or a binding doesn't raise ValueChanged or Completed. public event EventHandler? ValueChanged; // Raised when every slot is filled. public event EventHandler? Completed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double SlotSize = 40; private readonly Entry _entry; @@ -99,7 +103,7 @@ public InputOtp() ShellPlatform.StripNativeChrome(_entry); ShellPlatform.HideCaret(_entry); ShellFocus.Track(_entry); - _entry.TextChanged += (_, e) => Value = Sanitize(e.NewTextValue); + _entry.TextChanged += (_, e) => ByUser(() => Value = Sanitize(e.NewTextValue)); _entry.Focused += (_, _) => { _focused = true; UpdateSlots(); }; _entry.Unfocused += (_, _) => { _focused = false; UpdateSlots(); }; @@ -159,12 +163,21 @@ private void BuildSlots() OnValueChanged(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged() { var value = Sanitize(Value); if (value != Value) { Value = value; return; } if (_entry.Text != value) _entry.Text = value; UpdateSlots(); + if (!_byUser) return; ValueChanged?.Invoke(this, value); if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); } @@ -266,10 +279,14 @@ public bool HasError set => SetValue(HasErrorProperty, value); } + // User input only: setting Value from code or a binding doesn't raise ValueChanged or Completed. public event EventHandler? ValueChanged; // Raised when every slot is filled. public event EventHandler? Completed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public InputOtp() { // The real input: stretched over the slots and invisible, so a click anywhere focuses it. @@ -285,7 +302,7 @@ public InputOtp() _textBox.Text = value; _textBox.CaretIndex = value.Length; } - Value = value; + ByUser(() => Value = value); }; _textBox.GotFocus += (_, _) => { @@ -345,6 +362,14 @@ private void BuildSlots() OnValueChanged(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged() { var value = Sanitize(Value); @@ -359,6 +384,7 @@ private void OnValueChanged() _textBox.CaretIndex = value.Length; } UpdateSlots(); + if (!_byUser) return; ValueChanged?.Invoke(this, value); if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); } diff --git a/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs index 160c397..d878f20 100644 --- a/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs @@ -81,8 +81,12 @@ public string EmptyText public bool IsOpen { get; private set; } + // User input only: setting Values, or calling Toggle, from code doesn't raise it. public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemHeight = 32; private const double MaxListHeight = 240; @@ -153,7 +157,7 @@ public MultiSelect() _search.Completed += (_, _) => { var first = _rows.FirstOrDefault(r => r.Row.IsVisible); - if (first.Row != null) Toggle(first.Text); + if (first.Row != null) ToggleByUser(first.Text); }; var searchRow = new Grid @@ -234,6 +238,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); // Adds the option when it isn't picked, removes it when it is. + // A click, a tap or Enter on a row, or the × on a chip. + private void ToggleByUser(string text) => ByUser(() => Toggle(text)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string text) { // A new list each time, so a two-way binding sees the change. @@ -253,7 +268,7 @@ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e { RebuildChips(); UpdateChecks(); - ValuesChanged?.Invoke(this, Current); + if (_byUser) ValuesChanged?.Invoke(this, Current); } private void RebuildChips() @@ -284,7 +299,7 @@ private View CreateChip(string value) _chipTapped = true; // Cleared on the next turn in case the tap never reaches the trigger. Dispatcher.Dispatch(() => _chipTapped = false); - Toggle(value); + ToggleByUser(value); }; remove.GestureRecognizers.Add(tap); @@ -342,7 +357,7 @@ private View CreateItem(string text) pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; }; item.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => Toggle(text); + tap.Tapped += (_, _) => ToggleByUser(text); item.GestureRecognizers.Add(tap); _rows.Add((text, item, check)); @@ -465,8 +480,12 @@ public string? EmptyText public bool IsOpen => _popup.IsOpen; + // User input only: setting Values, or calling Toggle, from code doesn't raise it. public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public MultiSelect() { _placeholder = new TextBlock { Text = Placeholder, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0) }; @@ -572,6 +591,17 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); // Adds the option when it isn't picked, removes it when it is. + // A click, a tap or Enter on a row, or the × on a chip. + private void ToggleByUser(string text) => ByUser(() => Toggle(text)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Toggle(string text) { // A new list each time, so a two-way binding sees the change. @@ -593,7 +623,7 @@ private void OnSearchKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.Enter) { - if (_active >= 0) Toggle(_rows[_active].Text); + if (_active >= 0) ToggleByUser(_rows[_active].Text); } else if (e.Key == Key.Down) MoveActive(1); else if (e.Key == Key.Up) MoveActive(-1); @@ -612,7 +642,7 @@ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e { RebuildChips(); UpdateChecks(); - ValuesChanged?.Invoke(this, Current); + if (_byUser) ValuesChanged?.Invoke(this, Current); } private void RebuildChips() @@ -638,7 +668,7 @@ private Control CreateChip(string value) remove.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Toggle(value); + if (IsEffectivelyEnabled) ToggleByUser(value); e.Handled = true; }; @@ -690,7 +720,7 @@ private Control CreateItem(string text, int index) item.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - Toggle(text); + ToggleByUser(text); e.Handled = true; }; diff --git a/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs b/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs index e196698..9f2a4fd 100644 --- a/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/NavbarTemplate.cs @@ -111,8 +111,12 @@ public bool IsMenuOpen public IList Items => _items; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly ObservableCollection _items = new(); private readonly Icon _brandIcon; private readonly Label _title; @@ -184,16 +188,24 @@ public Navbar() UpdateBrand(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + internal void Select(NavbarItem item) { - if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + if (!string.IsNullOrEmpty(item.Value)) ByUser(() => SelectedValue = item.Value); IsMenuOpen = false; } private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } private void UpdateBrand() @@ -422,7 +434,7 @@ static Navbar() SelectedValueProperty.Changed.AddClassHandler((n, _) => { n.Refresh(); - n.SelectedValueChanged?.Invoke(n, n.SelectedValue); + if (n._byUser) n.SelectedValueChanged?.Invoke(n, n.SelectedValue); }); } @@ -472,8 +484,12 @@ public bool IsMenuOpen [Content] public Controls Items { get; } = new(); + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Navbar() { _brandIcon = new Icon { Size = 20, Token = ShellToken.Foreground, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; @@ -540,9 +556,17 @@ public Navbar() private List ItemList => Items.OfType().ToList(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + internal void Select(NavbarItem item) { - if (!string.IsNullOrEmpty(item.Value)) SelectedValue = item.Value; + if (!string.IsNullOrEmpty(item.Value)) ByUser(() => SelectedValue = item.Value); IsMenuOpen = false; } diff --git a/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs index d6a5e21..5ade053 100644 --- a/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs @@ -77,8 +77,12 @@ public string Placeholder set => SetValue(PlaceholderProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _frame; private readonly Entry _entry; private readonly Grid _decrease; @@ -159,12 +163,20 @@ private Grid StepButton(IconName icon, string description, int direction) { if (!IsEnabled) return; Commit(); - Value = (Value ?? Minimum ?? 0) + direction * Step; + ByUser(() => Value = (Value ?? Minimum ?? 0) + direction * Step); }; button.GestureRecognizers.Add(tap); return button; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private decimal? Clamp(decimal? value) { if (value is not { } number) return null; @@ -174,7 +186,9 @@ private Grid StepButton(IconName icon, string description, int direction) } // A typed number is clamped and kept, empty clears the value, anything else is reverted. - private void Commit() + private void Commit() => ByUser(CommitText); + + private void CommitText() { var text = _entry.Text?.Trim(); if (string.IsNullOrEmpty(text)) Value = null; @@ -194,7 +208,7 @@ private void OnValueChanged() { UpdateText(); UpdateButtons(); - ValueChanged?.Invoke(this, Value); + if (_byUser) ValueChanged?.Invoke(this, Value); } private void UpdateText() @@ -264,7 +278,7 @@ static NumberInput() { n.UpdateText(); n.UpdateButtons(); - n.ValueChanged?.Invoke(n, n.Value); + if (n._byUser) n.ValueChanged?.Invoke(n, n.Value); }); MinimumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); MaximumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); @@ -302,8 +316,12 @@ public string? Placeholder set => SetValue(PlaceholderProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public NumberInput() { _textBox = new TextBox @@ -388,7 +406,15 @@ private void StepBy(int direction) { if (!IsEffectivelyEnabled) return; Commit(); - Value = (Value ?? Minimum ?? 0) + direction * Step; + ByUser(() => Value = (Value ?? Minimum ?? 0) + direction * Step); + } + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } private decimal? Clamp(decimal? value) @@ -400,7 +426,9 @@ private void StepBy(int direction) } // A typed number is clamped and kept, empty clears the value, anything else is reverted. - private void Commit() + private void Commit() => ByUser(CommitText); + + private void CommitText() { var text = _textBox.Text?.Trim(); if (string.IsNullOrEmpty(text)) Value = null; diff --git a/src/ShellUI.Native.Templates/Templates/PaginationTemplate.cs b/src/ShellUI.Native.Templates/Templates/PaginationTemplate.cs index 60aa519..cea6e82 100644 --- a/src/ShellUI.Native.Templates/Templates/PaginationTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/PaginationTemplate.cs @@ -68,8 +68,12 @@ public bool ShowLabels set => SetValue(ShowLabelsProperty, value); } + // User input only: setting Page from code or a binding doesn't raise it. public event EventHandler? PageChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double ItemSize = 36; private readonly HorizontalStackLayout _row; @@ -85,7 +89,18 @@ public Pagination() private void OnPageChanged() { Rebuild(); - PageChanged?.Invoke(this, Page); + if (_byUser) PageChanged?.Invoke(this, Page); + } + + // A click or key on a page or an arrow. + private void GoTo(int page) => ByUser(() => Page = page); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } // Page is not clamped when set (XAML may set Page before TotalPages), only when shown. @@ -112,10 +127,10 @@ private List VisiblePages() private void Rebuild() { _row.Children.Clear(); - _row.Children.Add(NavItem(IconName.ChevronLeft, ""Previous"", iconFirst: true, enabled: Current > 1, () => Page = Current - 1)); + _row.Children.Add(NavItem(IconName.ChevronLeft, ""Previous"", iconFirst: true, enabled: Current > 1, () => GoTo(Current - 1))); foreach (var page in VisiblePages()) _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page)); - _row.Children.Add(NavItem(IconName.ChevronRight, ""Next"", iconFirst: false, enabled: Current < Total, () => Page = Current + 1)); + _row.Children.Add(NavItem(IconName.ChevronRight, ""Next"", iconFirst: false, enabled: Current < Total, () => GoTo(Current + 1))); } private View PageItem(int page) @@ -129,7 +144,7 @@ private View PageItem(int page) VerticalTextAlignment = TextAlignment.Center }; label.Token(Label.TextColorProperty, ShellToken.Foreground); - var item = Item(label, ItemSize, new Thickness(0), () => Page = page); + var item = Item(label, ItemSize, new Thickness(0), () => GoTo(page)); item.MinimumWidthRequest = ItemSize; if (current) { @@ -241,7 +256,7 @@ static Pagination() PageProperty.Changed.AddClassHandler((p, _) => { p.Rebuild(); - p.PageChanged?.Invoke(p, p.Page); + if (p._byUser) p.PageChanged?.Invoke(p, p.Page); }); TotalPagesProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); SiblingCountProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); @@ -272,8 +287,12 @@ public bool ShowLabels set => SetValue(ShowLabelsProperty, value); } + // User input only: setting Page from code or a binding doesn't raise it. public event EventHandler? PageChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Pagination() { Child = _row; @@ -281,6 +300,17 @@ public Pagination() Rebuild(); } + // A click or key on a page or an arrow. + private void GoTo(int page) => ByUser(() => Page = page); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // Page is not clamped when set (XAML may set Page before TotalPages), only when shown. private int Total => Math.Max(1, TotalPages); private int Current => Math.Clamp(Page, 1, Total); @@ -307,10 +337,10 @@ private void Rebuild() // The rebuild replaces the focused item, so keyboard focus moves to the new current page. var hadFocus = _row.IsKeyboardFocusWithin; _row.Children.Clear(); - _row.Children.Add(NavItem(IconName.ChevronLeft, ""Previous"", iconFirst: true, enabled: Current > 1, () => Page = Current - 1)); + _row.Children.Add(NavItem(IconName.ChevronLeft, ""Previous"", iconFirst: true, enabled: Current > 1, () => GoTo(Current - 1))); foreach (var page in VisiblePages()) _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page)); - _row.Children.Add(NavItem(IconName.ChevronRight, ""Next"", iconFirst: false, enabled: Current < Total, () => Page = Current + 1)); + _row.Children.Add(NavItem(IconName.ChevronRight, ""Next"", iconFirst: false, enabled: Current < Total, () => GoTo(Current + 1))); if (hadFocus) _currentItem?.Focus(NavigationMethod.Tab); } @@ -319,7 +349,7 @@ private Control PageItem(int page) var current = page == Current; var label = new TextBlock { Text = page.ToString(CultureInfo.CurrentCulture), FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); - var item = Item(label, ItemSize, new Thickness(0), current ? null : () => Page = page, focusable: true); + var item = Item(label, ItemSize, new Thickness(0), current ? null : () => GoTo(page), focusable: true); AutomationProperties.SetName(item, current ? $""Page {page}, current page"" : $""Go to page {page}""); if (current) { diff --git a/src/ShellUI.Native.Templates/Templates/RadioGroupItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/RadioGroupItemTemplate.cs index 9f040ad..8c78fd5 100644 --- a/src/ShellUI.Native.Templates/Templates/RadioGroupItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/RadioGroupItemTemplate.cs @@ -87,7 +87,7 @@ public RadioGroupItem() HorizontalOptions = LayoutOptions.Start; } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetChecked(bool isChecked) { @@ -196,7 +196,7 @@ protected override void OnKeyDown(KeyEventArgs e) e.Handled = true; } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetChecked(bool isChecked) { diff --git a/src/ShellUI.Native.Templates/Templates/RadioGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/RadioGroupTemplate.cs index 41d6b55..70bdbb1 100644 --- a/src/ShellUI.Native.Templates/Templates/RadioGroupTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/RadioGroupTemplate.cs @@ -34,8 +34,12 @@ public string Value set => SetValue(ValueProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly VerticalStackLayout _stack; public new IList Children => _stack.Children; @@ -49,11 +53,22 @@ public RadioGroup() public void SetValue(string value) => Value = value; + // A click or key on an item. + internal void SelectByUser(string value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnValueChanged(BindableObject b, object o, object n) { if (b is not RadioGroup group) return; group.RefreshItems(); - group.ValueChanged?.Invoke(group, n as string ?? string.Empty); + if (group._byUser) group.ValueChanged?.Invoke(group, n as string ?? string.Empty); } // The group drives its items, so item state never depends on when an item found its parent. @@ -83,7 +98,7 @@ static RadioGroup() ValueProperty.Changed.AddClassHandler((g, e) => { g.RefreshItems(); - g.ValueChanged?.Invoke(g, (string?)e.NewValue ?? string.Empty); + if (g._byUser) g.ValueChanged?.Invoke(g, (string?)e.NewValue ?? string.Empty); }); } @@ -93,8 +108,12 @@ public string? Value set => SetValue(ValueProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public RadioGroup() { Spacing = 12; @@ -103,6 +122,17 @@ public RadioGroup() public void SetValue(string? value) => Value = value; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // Items inside a nested panel may arrive after this group's own children. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { diff --git a/src/ShellUI.Native.Templates/Templates/SelectTemplate.cs b/src/ShellUI.Native.Templates/Templates/SelectTemplate.cs index 29dbec6..3369d86 100644 --- a/src/ShellUI.Native.Templates/Templates/SelectTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SelectTemplate.cs @@ -61,8 +61,12 @@ public string Placeholder public bool IsOpen { get; private set; } + // User input only: setting SelectedIndex from code or a binding doesn't raise it. public event EventHandler? SelectedIndexChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private ShellPopupHandle? _handle; private readonly Border _trigger; private readonly Label _value; @@ -143,12 +147,20 @@ public void SetOpen(bool open) public void Close() => SetOpen(false); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnSelectedIndexChanged(BindableObject b, object o, object n) { var select = (Select)b; select.UpdateTrigger(); select.UpdateItemStates(); - select.SelectedIndexChanged?.Invoke(select, EventArgs.Empty); + if (select._byUser) select.SelectedIndexChanged?.Invoke(select, EventArgs.Empty); } private void UpdateTrigger() @@ -199,7 +211,7 @@ private View CreateItem(string text, int index) pointer.PointerExited += (_, _) => { item.ClearValue(VisualElement.BackgroundColorProperty); item.BackgroundColor = Colors.Transparent; }; item.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { SelectedIndex = index; SetOpen(false); }; + tap.Tapped += (_, _) => { ByUser(() => SelectedIndex = index); SetOpen(false); }; item.GestureRecognizers.Add(tap); return item; } @@ -254,7 +266,7 @@ static Select() { s.UpdateTrigger(); s.UpdateItemStates(); - s.SelectedIndexChanged?.Invoke(s, EventArgs.Empty); + if (s._byUser) s.SelectedIndexChanged?.Invoke(s, EventArgs.Empty); }); ItemsSourceProperty.Changed.AddClassHandler((s, _) => s.UpdateTrigger()); @@ -284,8 +296,20 @@ public string? Placeholder public bool IsOpen => _popup.IsOpen; + // User input only: setting SelectedIndex from code or a binding doesn't raise it. public event EventHandler? SelectedIndexChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public Select() { _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; @@ -392,7 +416,7 @@ private Control CreateItem(string text, int index) item.PointerExited += (_, _) => { item.ClearToken(BackgroundProperty); item.Background = Brushes.Transparent; }; item.PointerReleased += (_, e) => { - SelectedIndex = index; + ByUser(() => SelectedIndex = index); SetOpen(false); e.Handled = true; }; diff --git a/src/ShellUI.Native.Templates/Templates/SidebarTemplate.cs b/src/ShellUI.Native.Templates/Templates/SidebarTemplate.cs index 2cf3b5b..57d2d44 100644 --- a/src/ShellUI.Native.Templates/Templates/SidebarTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SidebarTemplate.cs @@ -111,8 +111,12 @@ public View? Footer public IList Groups => _groups.Children; public event EventHandler? IsCollapsedChanged; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const string WidthAnimation = ""SidebarWidth""; private readonly ContentView _header; @@ -148,6 +152,17 @@ public Sidebar() public void Toggle() => IsCollapsed = !IsCollapsed; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnCollapsedChanged() { Refresh(); @@ -158,7 +173,7 @@ private void OnCollapsedChanged() private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } private void ApplyWidth(bool animate) @@ -345,7 +360,7 @@ public SidebarItem() private void Press() { if (!IsEnabled) return; - if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectByUser(Value); Clicked?.Invoke(this, EventArgs.Empty); } @@ -462,7 +477,7 @@ static Sidebar() SelectedValueProperty.Changed.AddClassHandler((s, _) => { s.Refresh(); - s.SelectedValueChanged?.Invoke(s, s.SelectedValue); + if (s._byUser) s.SelectedValueChanged?.Invoke(s, s.SelectedValue); }); ExpandedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); CollapsedWidthProperty.Changed.AddClassHandler((s, _) => s.SnapWidth()); @@ -518,8 +533,12 @@ public Control? Footer public Controls Groups => _groups.Children; public event EventHandler? IsCollapsedChanged; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Sidebar() { _groups.Children.CollectionChanged += (_, _) => Refresh(); @@ -545,6 +564,17 @@ public Sidebar() public void Toggle() => IsCollapsed = !IsCollapsed; + // A click or key on an item. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + // The width animates only once the sidebar is on screen, so it doesn't slide in at startup. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { @@ -748,7 +778,7 @@ protected override void OnKeyDown(KeyEventArgs e) public void Press() { if (!IsEffectivelyEnabled) return; - if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectedValue = Value; + if (!string.IsNullOrEmpty(Value) && this.FindParentOfType() is { } sidebar) sidebar.SelectByUser(Value); Clicked?.Invoke(this, EventArgs.Empty); } diff --git a/src/ShellUI.Native.Templates/Templates/SliderTemplate.cs b/src/ShellUI.Native.Templates/Templates/SliderTemplate.cs index e77f533..d5af8cb 100644 --- a/src/ShellUI.Native.Templates/Templates/SliderTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SliderTemplate.cs @@ -54,8 +54,13 @@ public double Maximum set => SetValue(MaximumProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while Value, Minimum or Maximum is pushed into the platform slider, which reports + // those changes like a drag. + private bool _syncing; + public Slider() { _native = new Microsoft.Maui.Controls.Slider { Minimum = 0, Maximum = 100, Value = 50 }; @@ -65,23 +70,29 @@ public Slider() _native.ValueChanged += (s, e) => { Value = e.NewValue; - ValueChanged?.Invoke(this, e); + if (!_syncing) ValueChanged?.Invoke(this, e); }; Content = _native; } private void SyncRange() { + var syncing = _syncing; + _syncing = true; // Widen before narrowing so Minimum never exceeds Maximum mid-update. if (Maximum > _native.Maximum) { _native.Maximum = Maximum; _native.Minimum = Minimum; } else { _native.Minimum = Minimum; _native.Maximum = Maximum; } SyncValue(); + _syncing = syncing; } private void SyncValue() { - if (Math.Abs(_native.Value - Value) > 0.001) - _native.Value = Value; + if (Math.Abs(_native.Value - Value) <= 0.001) return; + var syncing = _syncing; + _syncing = true; + _native.Value = Value; + _syncing = syncing; } } ", @@ -126,7 +137,7 @@ static Slider() ValueProperty.Changed.AddClassHandler((s, e) => { s.UpdateLayoutOfParts(); - s.ValueChanged?.Invoke(s, (double)e.NewValue!); + if (s._byUser) s.ValueChanged?.Invoke(s, (double)e.NewValue!); }); MinimumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); MaximumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); @@ -157,8 +168,20 @@ public double Step set => SetValue(StepProperty, value); } + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public Slider() { _track = new Border { Height = TrackHeight, CornerRadius = new CornerRadius(TrackHeight / 2), VerticalAlignment = VerticalAlignment.Center }; @@ -200,7 +223,7 @@ private void SetFromPointer(PointerEventArgs e) var travel = Bounds.Width - ThumbSize; if (travel <= 0) return; var fraction = Math.Clamp((e.GetPosition(this).X - ThumbSize / 2) / travel, 0, 1); - Value = Minimum + fraction * (Maximum - Minimum); + ByUser(() => Value = Minimum + fraction * (Maximum - Minimum)); } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -248,7 +271,7 @@ protected override void OnKeyDown(KeyEventArgs e) _ => null }; if (target is not { } value) return; - Value = value; + ByUser(() => Value = value); e.Handled = true; } } diff --git a/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs b/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs index 3424b10..000bffa 100644 --- a/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/StepperTemplate.cs @@ -86,10 +86,14 @@ public string ConfirmText public IList Steps => _steps; + // User input only: setting CurrentStep, or calling Next / Previous, from code doesn't raise it. public event EventHandler? CurrentStepChanged; // Raised by the Confirm button on the last step. public event EventHandler? Confirmed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double CircleSize = 32; private sealed class Marker(Border circle, Label number, Icon check, Label title, BoxView before, BoxView after) @@ -117,7 +121,7 @@ public Stepper() _body = new ContentView { Margin = new Thickness(0, 24, 0, 0) }; _previous = new Button { Variant = ButtonVariant.Outline, HorizontalOptions = LayoutOptions.Start }; - _previous.Clicked += (_, _) => Previous(); + _previous.Clicked += (_, _) => ByUser(Previous); _next = new Button { HorizontalOptions = LayoutOptions.End }; _next.Clicked += (_, _) => { @@ -126,7 +130,7 @@ public Stepper() _highest = Math.Max(_highest, _steps.Count - 1); Confirmed?.Invoke(this, EventArgs.Empty); } - else Next(); + else ByUser(Next); }; _navigation = new Grid { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; @@ -138,6 +142,14 @@ public Stepper() private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, _steps.Count - 1)); private bool IsLast => Current >= _steps.Count - 1; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (!IsLast) CurrentStep = Current + 1; @@ -152,7 +164,7 @@ private void OnCurrentStepChanged() { _highest = Math.Max(_highest, Current); UpdateState(); - CurrentStepChanged?.Invoke(this, Current); + if (_byUser) CurrentStepChanged?.Invoke(this, Current); } private void Rebuild() @@ -186,7 +198,7 @@ private void Rebuild() StrokeShape = new RoundRectangle { CornerRadius = CircleSize / 2 } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (index <= _highest) CurrentStep = index; }; + tap.Tapped += (_, _) => { if (index <= _highest) ByUser(() => CurrentStep = index); }; circle.GestureRecognizers.Add(tap); // Lines run from each circle to the edges of its column, so neighbors meet. @@ -411,14 +423,18 @@ public string ConfirmText [Content] public Controls Steps => _body.Children; + // User input only: setting CurrentStep, or calling Next / Previous, from code doesn't raise it. public event EventHandler? CurrentStepChanged; // Raised by the Confirm button on the last step. public event EventHandler? Confirmed; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Stepper() { _previous = new Button { Variant = ButtonVariant.Outline, HorizontalAlignment = HorizontalAlignment.Left }; - _previous.Clicked += (_, _) => Previous(); + _previous.Clicked += (_, _) => ByUser(Previous); _next = new Button { HorizontalAlignment = HorizontalAlignment.Right }; _next.Clicked += (_, _) => { @@ -427,7 +443,7 @@ public Stepper() _highest = Math.Max(_highest, StepList.Count - 1); Confirmed?.Invoke(this, EventArgs.Empty); } - else Next(); + else ByUser(Next); }; _navigation = new Panel { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; @@ -440,6 +456,14 @@ public Stepper() private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, StepList.Count - 1)); private bool IsLast => Current >= StepList.Count - 1; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Next() { if (!IsLast) CurrentStep = Current + 1; @@ -454,7 +478,7 @@ private void OnCurrentStepChanged() { _highest = Math.Max(_highest, Current); UpdateState(); - CurrentStepChanged?.Invoke(this, Current); + if (_byUser) CurrentStepChanged?.Invoke(this, Current); } private void Rebuild() @@ -493,13 +517,13 @@ private void Rebuild() circle.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left || index > _highest) return; - CurrentStep = index; + ByUser(() => CurrentStep = index); e.Handled = true; }; circle.KeyDown += (_, e) => { if (e.Key is not (Key.Enter or Key.Space) || index > _highest) return; - CurrentStep = index; + ByUser(() => CurrentStep = index); e.Handled = true; }; diff --git a/src/ShellUI.Native.Templates/Templates/SwitchTemplate.cs b/src/ShellUI.Native.Templates/Templates/SwitchTemplate.cs index a8f16ec..6691a4c 100644 --- a/src/ShellUI.Native.Templates/Templates/SwitchTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SwitchTemplate.cs @@ -57,8 +57,12 @@ public string Label set => SetValue(LabelProperty, value); } + // User input only: setting IsToggled from code or a binding doesn't raise it. public event EventHandler? Toggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Switch() { _thumb = new Border @@ -89,20 +93,28 @@ public Switch() var row = new HorizontalStackLayout { Spacing = 8, Children = { _track, _label } }; var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); IsToggled = !IsToggled; }; + tap.Tapped += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); ByUser(() => IsToggled = !IsToggled); }; row.GestureRecognizers.Add(tap); - ShellFocus.MakeFocusable(this, () => { if (IsEnabled) IsToggled = !IsToggled; }); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) ByUser(() => IsToggled = !IsToggled); }); Content = row; HorizontalOptions = LayoutOptions.Start; UpdateVisualState(animate: false); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private static void OnIsToggledChanged(BindableObject bindable, object oldValue, object newValue) { if (bindable is not Switch control) return; control.UpdateVisualState(animate: true); - control.Toggled?.Invoke(control, (bool)newValue); + if (control._byUser) control.Toggled?.Invoke(control, (bool)newValue); } protected override void OnPropertyChanged(string? propertyName = null) @@ -165,7 +177,7 @@ static Switch() IsToggledProperty.Changed.AddClassHandler((s, e) => { s.UpdateVisualState(); - s.Toggled?.Invoke(s, (bool)e.NewValue!); + if (s._byUser) s.Toggled?.Invoke(s, (bool)e.NewValue!); }); LabelProperty.Changed.AddClassHandler((s, _) => s.UpdateLabel()); IsEnabledProperty.Changed.AddClassHandler((s, _) => s.Opacity = s.IsEnabled ? 1.0 : 0.5); @@ -183,8 +195,12 @@ public string? Label set => SetValue(LabelProperty, value); } + // User input only: setting IsToggled from code or a binding doesn't raise it. public event EventHandler? Toggled; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Switch() { _thumb = new Border @@ -224,11 +240,19 @@ public Switch() }; } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsToggled = !IsToggled; + ByUser(() => IsToggled = !IsToggled); e.Handled = true; } @@ -236,7 +260,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key is not (Key.Space or Key.Enter)) return; - IsToggled = !IsToggled; + ByUser(() => IsToggled = !IsToggled); e.Handled = true; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs index 6c2f9a0..7759bed 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs @@ -43,8 +43,12 @@ public string Value } // (oldValue, newValue) + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler<(string Old, string New)>? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Tabs() { Spacing = 8; @@ -53,10 +57,21 @@ public Tabs() public void SetValue(string value) => Value = value; + // A click or key on a trigger. + internal void SelectByUser(string value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void OnValueChanged(string oldValue, string newValue) { Refresh(animate: true); - ValueChanged?.Invoke(this, (oldValue, newValue)); + if (_byUser) ValueChanged?.Invoke(this, (oldValue, newValue)); } // Tabs drives its triggers and panels, so state never depends on when a child found its parent. @@ -95,7 +110,7 @@ static Tabs() ValueProperty.Changed.AddClassHandler((t, e) => { t.Refresh(animate: t.IsLoaded); - t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + if (t._byUser) t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); }); } @@ -106,8 +121,12 @@ public string? Value } // (oldValue, newValue) + // User input only: setting Value from code or a binding doesn't raise it. public event EventHandler<(string Old, string New)>? ValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Tabs() { Spacing = 8; @@ -116,6 +135,17 @@ public Tabs() public void SetValue(string? value) => Value = value; + // A click or key on a trigger. + internal void SelectByUser(string? value) => ByUser(() => Value = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); diff --git a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs index 132b427..78fc0ac 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs @@ -79,7 +79,7 @@ public TabsTrigger() SetActive(false); } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetActive(bool active) { @@ -154,7 +154,7 @@ public TabsTrigger() SetActive(false); } - private void Select() => this.FindParentOfType()?.SetValue(Value); + private void Select() => this.FindParentOfType()?.SelectByUser(Value); internal void SetActive(bool active) { diff --git a/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs index 406457e..97b4998 100644 --- a/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs @@ -67,8 +67,12 @@ public bool AllowDuplicates set => SetValue(AllowDuplicatesProperty, value); } + // User input only: setting Tags, or calling Add / Remove, from code doesn't raise it. public event EventHandler>? TagsChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private static readonly char[] Separators = { ',', ';', '\n', '\t' }; private readonly Border _frame; @@ -127,6 +131,14 @@ public TagInput() private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Add(string tag) { var text = tag.Trim(); @@ -161,7 +173,9 @@ private void KeepTyping() }); } - private void AddFromEntry() + private void AddFromEntry() => ByUser(AddEntryText); + + private void AddEntryText() { var text = _entry.Text ?? string.Empty; _entry.Text = string.Empty; @@ -179,7 +193,7 @@ private void OnTagsReplaced(object? oldValue, object? newValue) private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Rebuild(); - TagsChanged?.Invoke(this, Current); + if (_byUser) TagsChanged?.Invoke(this, Current); } private void Rebuild() @@ -207,7 +221,7 @@ private View CreateChip(string tag) pointer.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; remove.GestureRecognizers.Add(pointer); var tap = new TapGestureRecognizer(); - tap.Tapped += (_, _) => { if (IsEnabled) Remove(tag); }; + tap.Tapped += (_, _) => { if (IsEnabled) ByUser(() => Remove(tag)); }; remove.GestureRecognizers.Add(tap); var chip = new Border @@ -306,8 +320,12 @@ public bool AllowDuplicates set => SetValue(AllowDuplicatesProperty, value); } + // User input only: setting Tags, or calling Add / Remove, from code doesn't raise it. public event EventHandler>? TagsChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public TagInput() { _textBox = new TextBox @@ -328,7 +346,7 @@ public TagInput() } else if (e.Key == Key.Back && string.IsNullOrEmpty(_textBox.Text) && Current.Count > 0) { - Remove(Current[^1]); + ByUser(() => Remove(Current[^1])); e.Handled = true; } }, RoutingStrategies.Tunnel); @@ -362,6 +380,14 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public void Add(string tag) { var text = tag.Trim(); @@ -379,7 +405,9 @@ public void Remove(string tag) if (next.Remove(tag)) Tags = next; } - private void AddFromText() + private void AddFromText() => ByUser(AddTypedText); + + private void AddTypedText() { var text = _textBox.Text ?? string.Empty; _textBox.Text = string.Empty; @@ -397,7 +425,7 @@ private void OnTagsReplaced(object? oldValue, object? newValue) private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Rebuild(); - TagsChanged?.Invoke(this, Current); + if (_byUser) TagsChanged?.Invoke(this, Current); } private void Rebuild() @@ -423,7 +451,7 @@ private Control CreateChip(string tag) remove.PointerReleased += (_, e) => { if (e.InitialPressMouseButton != MouseButton.Left) return; - if (IsEffectivelyEnabled) Remove(tag); + if (IsEffectivelyEnabled) ByUser(() => Remove(tag)); e.Handled = true; }; diff --git a/src/ShellUI.Native.Templates/Templates/TimePickerTemplate.cs b/src/ShellUI.Native.Templates/Templates/TimePickerTemplate.cs index 48ae8bb..07301f5 100644 --- a/src/ShellUI.Native.Templates/Templates/TimePickerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TimePickerTemplate.cs @@ -74,8 +74,12 @@ public string? Format public bool IsOpen { get; private set; } + // User input only: setting Time from code or a binding doesn't raise it. public event EventHandler? TimeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private const double CellHeight = 32; private const double CellSpacing = 2; private const double ColumnHeight = 7 * CellHeight + 6 * CellSpacing; @@ -173,7 +177,7 @@ private static void OnTimeChanged(BindableObject b, object o, object n) var picker = (TimePicker)b; picker.UpdateText(); picker.UpdateSelection(); - picker.TimeChanged?.Invoke(picker, EventArgs.Empty); + if (picker._byUser) picker.TimeChanged?.Invoke(picker, EventArgs.Empty); } private void UpdateText() @@ -260,12 +264,20 @@ private ScrollView AddColumn(List cells, IEnumerable values, Func Time.Hours >= 12; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void SetHour(int hour) => - Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0); + ByUser(() => Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0)); - private void SetMinute(int minute) => Time = new TimeSpan(Time.Hours, minute, 0); + private void SetMinute(int minute) => ByUser(() => Time = new TimeSpan(Time.Hours, minute, 0)); - private void SetPeriod(int period) => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0); + private void SetPeriod(int period) => ByUser(() => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0)); private void UpdateSelection() { @@ -369,7 +381,7 @@ static TimePicker() { p.UpdateText(); p.UpdateSelection(); - p.TimeChanged?.Invoke(p, EventArgs.Empty); + if (p._byUser) p.TimeChanged?.Invoke(p, EventArgs.Empty); }); MinuteStepProperty.Changed.AddClassHandler((p, _) => p.BuildColumns()); Is24HourProperty.Changed.AddClassHandler((p, _) => { p.BuildColumns(); p.UpdateText(); }); @@ -403,8 +415,12 @@ public string? Format public bool IsOpen => _popup.IsOpen; + // User input only: setting Time from code or a binding doesn't raise it. public event EventHandler? TimeChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public TimePicker() { _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; @@ -558,12 +574,20 @@ private ScrollViewer AddColumn(List cells, IEnumerable values, Func Time.Hours >= 12; + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private void SetHour(int hour) => - Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0); + ByUser(() => Time = new TimeSpan(Is24Hour ? hour : (hour % 12) + (IsPm ? 12 : 0), Time.Minutes, 0)); - private void SetMinute(int minute) => Time = new TimeSpan(Time.Hours, minute, 0); + private void SetMinute(int minute) => ByUser(() => Time = new TimeSpan(Time.Hours, minute, 0)); - private void SetPeriod(int period) => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0); + private void SetPeriod(int period) => ByUser(() => Time = new TimeSpan((Time.Hours % 12) + (period == 1 ? 12 : 0), Time.Minutes, 0)); private void UpdateSelection() { diff --git a/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs index bf10891..cea4062 100644 --- a/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs @@ -75,9 +75,13 @@ public ToggleGroupVariant Variant public IList Items => _row.Children; + // User input only: setting Value or Values from code or a binding doesn't raise them. public event EventHandler? ValueChanged; public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly HorizontalStackLayout _row; public ToggleGroup() @@ -92,7 +96,18 @@ public ToggleGroup() public bool IsPressed(string value) => Multiple ? Values?.Contains(value) == true : Value == value; - internal void Toggle(string value) + // A click or key on an item. + internal void Toggle(string value) => ByUser(() => ToggleValue(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + + private void ToggleValue(string value) { if (!IsEnabled) return; if (Multiple) @@ -111,7 +126,7 @@ internal void Toggle(string value) private void OnValueChanged() { Refresh(); - ValueChanged?.Invoke(this, Value); + if (_byUser) ValueChanged?.Invoke(this, Value); } private void OnValuesReplaced(object? oldValue, object? newValue) @@ -124,7 +139,7 @@ private void OnValuesReplaced(object? oldValue, object? newValue) private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Refresh(); - ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + if (_byUser) ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); } // Items don't track the group's state: the group pushes it to them. @@ -308,7 +323,7 @@ static ToggleGroup() ValueProperty.Changed.AddClassHandler((g, _) => { g.Refresh(); - g.ValueChanged?.Invoke(g, g.Value); + if (g._byUser) g.ValueChanged?.Invoke(g, g.Value); }); ValuesProperty.Changed.AddClassHandler((g, e) => g.OnValuesReplaced(e.OldValue, e.NewValue)); } @@ -340,9 +355,13 @@ public ToggleGroupVariant Variant [Content] public Controls Items => _row.Children; + // User input only: setting Value or Values from code or a binding doesn't raise them. public event EventHandler? ValueChanged; public event EventHandler>? ValuesChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public ToggleGroup() { _row.Children.CollectionChanged += (_, _) => Refresh(); @@ -353,7 +372,18 @@ public ToggleGroup() public bool IsPressed(string? value) => value != null && (Multiple ? Values?.Contains(value) == true : Value == value); - internal void Toggle(string? value) + // A click or key on an item. + internal void Toggle(string? value) => ByUser(() => ToggleValue(value)); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + + private void ToggleValue(string? value) { if (value is null || !IsEffectivelyEnabled) return; if (Multiple) @@ -379,7 +409,7 @@ private void OnValuesReplaced(object? oldValue, object? newValue) private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) { Refresh(); - ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + if (_byUser) ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); } // Items don't track the group's state: the group pushes it to them. diff --git a/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs index fe4096b..6439c9b 100644 --- a/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs @@ -76,8 +76,12 @@ public ToggleSize Size set => SetValue(SizeProperty, value); } + // User input only: setting IsPressed from code or a binding doesn't raise it. public event EventHandler? PressedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + private readonly Border _border; private readonly Icon _icon; private readonly Label _label; @@ -122,13 +126,21 @@ public Toggle() private void Press() { - if (IsEnabled) IsPressed = !IsPressed; + if (IsEnabled) ByUser(() => IsPressed = !IsPressed); + } + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } } private void OnPressedChanged() { UpdateVisualState(); - PressedChanged?.Invoke(this, IsPressed); + if (_byUser) PressedChanged?.Invoke(this, IsPressed); } protected override void OnPropertyChanged(string? propertyName = null) @@ -217,7 +229,7 @@ static Toggle() IsPressedProperty.Changed.AddClassHandler((t, _) => { t.UpdateVisualState(); - t.PressedChanged?.Invoke(t, t.IsPressed); + if (t._byUser) t.PressedChanged?.Invoke(t, t.IsPressed); }); TextProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); IconProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); @@ -256,8 +268,12 @@ public ToggleSize Size set => SetValue(SizeProperty, value); } + // User input only: setting IsPressed from code or a binding doesn't raise it. public event EventHandler? PressedChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + public Toggle() { _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; @@ -289,11 +305,19 @@ protected override void OnPointerExited(PointerEventArgs e) UpdateVisualState(); } + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + protected override void OnPointerReleased(PointerReleasedEventArgs e) { base.OnPointerReleased(e); if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; - IsPressed = !IsPressed; + ByUser(() => IsPressed = !IsPressed); e.Handled = true; } @@ -301,7 +325,7 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return; - IsPressed = !IsPressed; + ByUser(() => IsPressed = !IsPressed); e.Handled = true; } diff --git a/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs index cf24993..92599e3 100644 --- a/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs @@ -45,8 +45,23 @@ public string? SelectedValue public IList Items => _stack.Children; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + // A click or key on a row. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + private readonly VerticalStackLayout _stack; public TreeView() @@ -59,7 +74,7 @@ public TreeView() private void OnSelectedValueChanged() { Refresh(); - SelectedValueChanged?.Invoke(this, SelectedValue); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); } // Items don't track the selection: the tree pushes it to every item under it. @@ -189,7 +204,7 @@ private void Press() { if (!IsEnabled) return; if (HasChildren) IsExpanded = !IsExpanded; - if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + if (this.FindParentOfType() is { } tree) tree.SelectByUser(EffectiveValue); } internal void ApplySelection(string? selected) @@ -269,7 +284,7 @@ static TreeView() SelectedValueProperty.Changed.AddClassHandler((t, _) => { t.Refresh(); - t.SelectedValueChanged?.Invoke(t, t.SelectedValue); + if (t._byUser) t.SelectedValueChanged?.Invoke(t, t.SelectedValue); }); } @@ -282,8 +297,23 @@ public string? SelectedValue [Content] public Controls Items => _stack.Children; + // User input only: setting SelectedValue from code or a binding doesn't raise it. public event EventHandler? SelectedValueChanged; + // Set while user input changes the value, so change events report user input only. + private bool _byUser; + + // A click or key on a row. + internal void SelectByUser(string? value) => ByUser(() => SelectedValue = value); + + private void ByUser(Action change) + { + var outer = _byUser; + _byUser = true; + try { change(); } + finally { _byUser = outer; } + } + public TreeView() { Child = _stack; @@ -420,7 +450,7 @@ private void Press() { if (!IsEffectivelyEnabled) return; if (HasChildren) IsExpanded = !IsExpanded; - if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + if (this.FindParentOfType() is { } tree) tree.SelectByUser(EffectiveValue); } internal void ApplySelection(string? selected) From b300d4656a4937b1fb2406f8418fbf36aacff417 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 11:29:25 +0200 Subject: [PATCH 07/11] docs: user-input-only change events, sheet width, label alignment, hybrid folder --- docs/COMPONENTS.md | 24 +++++++++++++++++++++--- docs/DEVELOPMENT_PLAN.md | 4 ++-- docs/QUICKSTART.md | 3 ++- src/ShellUI.Native.CLI/README.md | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 60ee962..2edec9f 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -66,6 +66,24 @@ your background so the splash-to-app transition doesn't flash the template's pur --- +## Change events + +Events that report a new value or selection (`Toggled`, `CheckedChanged`, `ValueChanged`, +`SelectedIndexChanged`, `SelectedValueChanged`, `PageChanged`, `DateChanged`, `TimeChanged`, +`ItemToggled` and the like) fire only for user input: a click, tap, swipe or key. Setting the +property from code or a binding updates the control without raising them, like Radix's +`onValueChange`, so filling a form from data or syncing a sidebar with the router doesn't look +like the user did it. To see every change, bind the property or watch it (`PropertyChanged` on +MAUI, `GetObservable` on Avalonia). The same goes for methods meant for code: `MultiSelect.Toggle`, +`TagInput.Add` / `Remove`, `DataTable.ClearSelection`, `DateRangePicker.Clear`, `Stepper.Next` / +`Previous`, `Carousel.Next` / `Previous` (and auto-play), `Accordion.Toggle`, `Collapsible.Toggle`. + +These still report every change: text fields' `TextChanged` (the platform's own event), +`OpenChanged` / `IsOpenChanged` on overlays, `ThemeChanged`, `FileUpload.FilesChanged` (only the +file picker changes it) and `Sidebar.IsCollapsedChanged` (only code collapses a sidebar). + +--- + ## Avalonia Every component on this page has an Avalonia version, installed by the same CLI commands @@ -665,7 +683,7 @@ Typography with size, weight and color variants. shellui-native add label ``` -**Properties:** `Text`, `Size` (Xs…Xxxl), `Weight` (Light…Bold), `Variant` (Default, Muted, Destructive, Success, Warning) +**Properties:** `Text`, `Size` (Xs…Xxxl), `Weight` (Light…Bold), `Variant` (Default, Muted, Destructive, Success, Warning), `HorizontalTextAlignment` and `LineBreakMode` (MAUI; `WordWrap` by default). On Avalonia `ShellLabel` is a `TextBlock`, so `TextAlignment` and `TextWrapping` do the same. ```xml @@ -1537,7 +1555,7 @@ A line per series with the space under it filled. **Properties:** `IsSmooth` (tr ### Pie Chart and Donut Chart The first series' values as slices, clockwise from twelve o'clock, named by `Labels` and colored from the chart colors in order. The tooltip shows a slice's value and its share. `DonutChart` is -a pie with a hole. **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees +a pie with a hole, in the same template (`shellui-native add pie-chart`, or `add donut-chart`). **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees between slices; 1), `ShowLabels` (the value outside each slice), `CenterValue`, `CenterLabel` (text in a donut's hole) @@ -1601,7 +1619,7 @@ shellui-native add drawer shellui-native add sheet ``` -**Properties:** `Open` (also `SetOpen(bool)`) · **Events:** `OpenChanged` · Drawer/Sheet: `Side` (Left, Right, Top, Bottom) +**Properties:** `Open` (also `SetOpen(bool)`) · **Events:** `OpenChanged` · Drawer/Sheet: `Side` (Left, Right, Top, Bottom) · `SheetContent.MaxPanelWidth`: how wide a left or right sheet gets (384 by default, shadcn's `sm:max-w-sm`; never more than 75% of the window) ```xml diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index b047cc8..43d2bfe 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -39,8 +39,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#21 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | -| `feat/charts` | `main` | **Active** | chart core plus bar, line, area, pie / donut, radar and radial charts for MAUI and Avalonia (114 registry entries, 111 CLI targets, 80 families) | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#23 merged since (charts in #23) | 114 registry entries (111 CLI targets, 80 families), each with MAUI and Avalonia content | +| `fix/pre-release-issues` | `main` | **Active** | Issues before `0.2.0-alpha.1`: change events report user input only (#24), sheet `MaxPanelWidth` (#26), `ShellLabel` alignment and line break (#27), native components beside ShellUI Blazor (#28), `donut-chart` alias and did-you-mean | --- diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 0f8ec8f..372fc5e 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -31,7 +31,8 @@ shellui-native init --yes This will: - Detect your project type (MAUI, Avalonia, WinUI, or WPF — see [ARCHITECTURE.md § Platform Detection](./ARCHITECTURE.md#platform-detection)) -- Create the `Components/UI/` folder +- Create the `Components/UI/` folder (`Components/Native/` when the project also has ShellUI for + Blazor, see [BLAZOR_HYBRID.md](./BLAZOR_HYBRID.md)) - Install the `Shell.cs` utility - Create `shellui-native.json` configuration - Generate theme resources (MAUI) diff --git a/src/ShellUI.Native.CLI/README.md b/src/ShellUI.Native.CLI/README.md index 9befe31..6e20d2d 100644 --- a/src/ShellUI.Native.CLI/README.md +++ b/src/ShellUI.Native.CLI/README.md @@ -35,7 +35,7 @@ shellui-native init --yes shellui-native add button input card ``` -Components are written to `Components/UI/` as plain C# files you own. `init` detects whether the project is MAUI or Avalonia and installs that platform's version of each component. +Components are written to `Components/UI/` as plain C# files you own (`Components/Native/` in a project that also uses ShellUI for Blazor). `init` detects whether the project is MAUI or Avalonia and installs that platform's version of each component. ## More Information From 77f29cfa70cf0ea134bd3f4c254f7878bfc01f64 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 11:43:52 +0200 Subject: [PATCH 08/11] feat: donut-chart is its own component, as in ShellUI DonutChart moves from PieChart.cs into DonutChart.cs on MAUI and Avalonia, registered as donut-chart with a dependency on pie-chart, so 'add donut-chart' works and 'list' shows it like ShellUI (Blazor). The chart names now match ShellUI's. The donut-chart alias is gone; unknown names still get suggestions. --- .../Avalonia.Demo/Components/UI/DonutChart.cs | 13 +++++ .../Avalonia.Demo/Components/UI/PieChart.cs | 14 +----- .../MAUI.Demo/Components/UI/DonutChart.cs | 13 +++++ examples/MAUI.Demo/Components/UI/PieChart.cs | 14 +----- scripts/sync-templates.py | 7 ++- .../Services/ComponentInstaller.cs | 5 +- .../Services/ComponentManager.cs | 4 +- .../ComponentRegistry.cs | 16 +----- .../Templates/DonutChartTemplate.cs | 49 +++++++++++++++++++ .../Templates/PieChartTemplate.cs | 28 +---------- .../ComponentRegistryTests.cs | 24 +++++---- .../TemplateContentTests.cs | 1 + 12 files changed, 105 insertions(+), 83 deletions(-) create mode 100644 examples/Avalonia.Demo/Components/UI/DonutChart.cs create mode 100644 examples/MAUI.Demo/Components/UI/DonutChart.cs create mode 100644 src/ShellUI.Native.Templates/Templates/DonutChartTemplate.cs diff --git a/examples/Avalonia.Demo/Components/UI/DonutChart.cs b/examples/Avalonia.Demo/Components/UI/DonutChart.cs new file mode 100644 index 0000000..e3a1ad1 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/DonutChart.cs @@ -0,0 +1,13 @@ +namespace AvaloniaDemo.Components.UI; + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +// +// +// +public class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/PieChart.cs b/examples/Avalonia.Demo/Components/UI/PieChart.cs index 1f0ef36..60e0649 100644 --- a/examples/Avalonia.Demo/Components/UI/PieChart.cs +++ b/examples/Avalonia.Demo/Components/UI/PieChart.cs @@ -9,13 +9,10 @@ namespace AvaloniaDemo.Components.UI; // Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by // Labels and colored from the theme's chart colors in order. The hovered slice stands out and -// shows its value and share. DonutChart is the same with a hole for a value and a label. +// shows its value and share. For a pie with a hole, see DonutChart (donut-chart). // // // -// -// -// public class PieChart : ChartBase { // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. @@ -141,12 +138,3 @@ protected override (string Title, List<(Color Color, string Name, string Value)> }); } } - -// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. -public class DonutChart : PieChart -{ - public DonutChart() - { - InnerRadius = 0.6; - } -} diff --git a/examples/MAUI.Demo/Components/UI/DonutChart.cs b/examples/MAUI.Demo/Components/UI/DonutChart.cs new file mode 100644 index 0000000..f178cab --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/DonutChart.cs @@ -0,0 +1,13 @@ +namespace MAUI.Demo.Components.UI; + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +// +// +// +public partial class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} diff --git a/examples/MAUI.Demo/Components/UI/PieChart.cs b/examples/MAUI.Demo/Components/UI/PieChart.cs index 75ce62c..f3e38c7 100644 --- a/examples/MAUI.Demo/Components/UI/PieChart.cs +++ b/examples/MAUI.Demo/Components/UI/PieChart.cs @@ -4,13 +4,10 @@ namespace MAUI.Demo.Components.UI; // Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by // Labels and colored from the theme's chart colors in order. The hovered slice stands out and -// shows its value and share. DonutChart is the same with a hole for a value and a label. +// shows its value and share. For a pie with a hole, see DonutChart (donut-chart). // // // -// -// -// public partial class PieChart : ChartBase { // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. @@ -136,12 +133,3 @@ protected override (string Title, List<(Color Color, string Name, string Value)> }); } } - -// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. -public partial class DonutChart : PieChart -{ - public DonutChart() - { - InnerRadius = 0.6; - } -} diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py index 20958e1..c8dab29 100644 --- a/scripts/sync-templates.py +++ b/scripts/sync-templates.py @@ -118,8 +118,10 @@ ["chart", "line", "graph", "trend"]), "AreaChart": ("area-chart", "Area Chart", "Filled lines, overlapping or stacked", "DataDisplay", ["chart", "area", "graph", "stacked"]), - "PieChart": ("pie-chart", "Pie Chart", "Pie and donut charts", "DataDisplay", - ["chart", "pie", "donut", "share"]), + "PieChart": ("pie-chart", "Pie Chart", "Slices of a whole, clockwise from twelve o'clock", "DataDisplay", + ["chart", "pie", "share"]), + "DonutChart": ("donut-chart", "Donut Chart", "A pie chart with a hole for a value and a label", "DataDisplay", + ["chart", "donut", "pie", "share"]), "RadarChart": ("radar-chart", "Radar Chart", "Series compared across several axes", "DataDisplay", ["chart", "radar", "spider", "compare"]), "RadialChart": ("radial-chart", "Radial Chart", "Concentric progress rings", "DataDisplay", @@ -141,6 +143,7 @@ "LineChart": ["chart"], "AreaChart": ["chart"], "PieChart": ["chart"], + "DonutChart": ["pie-chart"], "RadarChart": ["chart"], "RadialChart": ["chart"], "TagInput": ["wrap-layout"], diff --git a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs index 67821fd..561f3ac 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs @@ -32,11 +32,8 @@ public static async Task InstallComponents(string[] components, bool force) // Parse comma-separated components; a part installs its whole family var componentList = new List(); - foreach (var requested in components.SelectMany(c => c.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) + foreach (var comp in components.SelectMany(c => c.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) { - var comp = ComponentRegistry.Resolve(requested); - if (comp != requested.ToLower()) - AnsiConsole.MarkupLine($"[dim]'{requested}' is in '{comp}', installing '{comp}'[/]"); var family = ComponentRegistry.GetFamily(comp); if (family != null) AnsiConsole.MarkupLine($"[dim]'{comp}' is part of '{family}', installing '{family}'[/]"); diff --git a/src/ShellUI.Native.CLI/Services/ComponentManager.cs b/src/ShellUI.Native.CLI/Services/ComponentManager.cs index 7a99229..4e1a094 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentManager.cs @@ -87,7 +87,7 @@ public static void RemoveComponents(string[] components) var removedCount = 0; - foreach (var componentName in components.Select(ComponentRegistry.Resolve)) + foreach (var componentName in components) { var metadata = ComponentRegistry.GetMetadata(componentName); if (metadata == null) @@ -147,7 +147,7 @@ public static void UpdateComponents(string[] components, bool all) } else { - componentsToUpdate = components.Select(ComponentRegistry.Resolve); + componentsToUpdate = components; } var updatedCount = 0; diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index d41de0e..ece3888 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -142,16 +142,11 @@ public static class ComponentRegistry { "line-chart", (LineChartTemplate.Metadata, LineChartTemplate.Contents) }, { "area-chart", (AreaChartTemplate.Metadata, AreaChartTemplate.Contents) }, { "pie-chart", (PieChartTemplate.Metadata, PieChartTemplate.Contents) }, + { "donut-chart", (DonutChartTemplate.Metadata, DonutChartTemplate.Contents) }, { "radar-chart", (RadarChartTemplate.Metadata, RadarChartTemplate.Contents) }, { "radial-chart", (RadialChartTemplate.Metadata, RadialChartTemplate.Contents) } }; - // Names people try for a component that lives in another template. - private static readonly Dictionary _aliases = new() - { - { "donut-chart", "pie-chart" } - }; - // Metadata-only view for callers that don't need the per-platform payload. public static IReadOnlyDictionary Components { get; } = _templates.ToDictionary(kv => kv.Key, kv => kv.Value.Meta); @@ -201,13 +196,6 @@ public static bool Exists(string componentName) return _templates.ContainsKey(componentName.ToLower()); } - // The registry name for `name`: the template an alias points to, or `name` itself. - public static string Resolve(string name) - { - var key = name.ToLower(); - return _aliases.TryGetValue(key, out var target) ? target : key; - } - // Up to `max` registered names close to an unknown `name`, closest first: a typo // ("buton"), or a shared word ("chart-04", "chart-bar"). public static IReadOnlyList Suggest(string name, int max = 3) @@ -215,7 +203,7 @@ public static IReadOnlyList Suggest(string name, int max = 3) var key = name.ToLower(); var limit = Math.Max(2, key.Length / 3); var words = key.Split('-', StringSplitOptions.RemoveEmptyEntries).Where(w => w.Length >= 3).ToList(); - return _templates.Keys.Concat(_aliases.Keys) + return _templates.Keys .Select(candidate => (candidate, distance: Distance(key, candidate))) .Where(c => c.distance <= limit || words.Any(c.candidate.Split('-').Contains)) .OrderBy(c => c.distance) diff --git a/src/ShellUI.Native.Templates/Templates/DonutChartTemplate.cs b/src/ShellUI.Native.Templates/Templates/DonutChartTemplate.cs new file mode 100644 index 0000000..39f4ee0 --- /dev/null +++ b/src/ShellUI.Native.Templates/Templates/DonutChartTemplate.cs @@ -0,0 +1,49 @@ +using ShellUI.Native.Core.Models; + +namespace ShellUI.Native.Templates.Templates; + +public static class DonutChartTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "donut-chart", + DisplayName = "Donut Chart", + Description = "A pie chart with a hole for a value and a label", + Category = ComponentCategory.DataDisplay, + FilePath = "DonutChart.cs", + Dependencies = new List { "pie-chart" }, + Tags = new List { "chart", "donut", "pie", "share" } + }; + + public static IReadOnlyDictionary Contents { get; } = new Dictionary + { + [NativePlatform.MAUI] = @"namespace YourProjectNamespace.Components.UI; + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +// +// +// +public partial class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} +", + [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI; + +// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. +// +// +// +public class DonutChart : PieChart +{ + public DonutChart() + { + InnerRadius = 0.6; + } +} +" + }; +} diff --git a/src/ShellUI.Native.Templates/Templates/PieChartTemplate.cs b/src/ShellUI.Native.Templates/Templates/PieChartTemplate.cs index 438f571..1c78433 100644 --- a/src/ShellUI.Native.Templates/Templates/PieChartTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/PieChartTemplate.cs @@ -23,13 +23,10 @@ namespace YourProjectNamespace.Components.UI; // Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by // Labels and colored from the theme's chart colors in order. The hovered slice stands out and -// shows its value and share. DonutChart is the same with a hole for a value and a label. +// shows its value and share. For a pie with a hole, see DonutChart (donut-chart). // // // -// -// -// public partial class PieChart : ChartBase { // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. @@ -155,15 +152,6 @@ protected override (string Title, List<(Color Color, string Name, string Value)> }); } } - -// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. -public partial class DonutChart : PieChart -{ - public DonutChart() - { - InnerRadius = 0.6; - } -} ", [NativePlatform.Avalonia] = @"using System; using System.Collections.Generic; @@ -176,13 +164,10 @@ namespace YourProjectNamespace.Components.UI; // Pie chart — the first series' values as slices, clockwise from twelve o'clock, named by // Labels and colored from the theme's chart colors in order. The hovered slice stands out and -// shows its value and share. DonutChart is the same with a hole for a value and a label. +// shows its value and share. For a pie with a hole, see DonutChart (donut-chart). // // // -// -// -// public class PieChart : ChartBase { // The hole, as a share of the radius: 0 is a pie, 0.6 a donut. @@ -308,15 +293,6 @@ protected override (string Title, List<(Color Color, string Name, string Value)> }); } } - -// A pie with a hole — InnerRadius starts at 0.6, with CenterValue and CenterLabel in the middle. -public class DonutChart : PieChart -{ - public DonutChart() - { - InnerRadius = 0.6; - } -} " }; } diff --git a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs index 3151b78..d20d5e5 100644 --- a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs +++ b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs @@ -171,21 +171,27 @@ public void Component_version_is_the_package_version_from_Directory_Build_props( Assert.All(ComponentRegistry.Components.Values, m => Assert.Equal(expected, m.Version)); } + // The charts are added by the same names as in ShellUI (Blazor). Its chart-series, + // multi-series-chart, chart-variants and chart-styles are covered by the chart core here. [Theory] - [InlineData("donut-chart", "pie-chart")] - [InlineData("Donut-Chart", "pie-chart")] - [InlineData("button", "button")] - [InlineData("not-a-component", "not-a-component")] - public void Resolve_maps_aliases_to_their_template(string name, string expected) + [InlineData("chart")] + [InlineData("bar-chart")] + [InlineData("line-chart")] + [InlineData("area-chart")] + [InlineData("pie-chart")] + [InlineData("donut-chart")] + [InlineData("radar-chart")] + [InlineData("radial-chart")] + public void Charts_use_ShellUI_names(string name) { - Assert.Equal(expected, ComponentRegistry.Resolve(name)); + Assert.True(ComponentRegistry.Exists(name), name); } [Fact] - public void Every_alias_points_to_a_registered_component() + public void Donut_chart_builds_on_the_pie_chart() { - foreach (var name in new[] { "donut-chart" }) - Assert.True(ComponentRegistry.Exists(ComponentRegistry.Resolve(name)), name); + Assert.Contains("pie-chart", ComponentRegistry.GetMetadata("donut-chart")!.Dependencies); + Assert.Contains("class DonutChart : PieChart", ComponentRegistry.GetComponentContent("donut-chart", NativePlatform.Avalonia)!); } [Theory] diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 6c76ec2..066283b 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -134,6 +134,7 @@ public static IEnumerable AvaloniaComponents() [InlineData("line-chart")] [InlineData("area-chart")] [InlineData("pie-chart")] + [InlineData("donut-chart")] [InlineData("radar-chart")] [InlineData("radial-chart")] public void Ported_components_have_Avalonia_content(string name) From 4a070fc99d7b061c9060b591c5665066b10a28d4 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 11:43:52 +0200 Subject: [PATCH 09/11] test: every type a template uses comes from its dependencies For each component and platform, types from other templates (in type positions: new, declarations, generics, casts, base types) must come from its declared dependencies, its family or shell, so a component added on its own builds. 230 cases; removing a real dependency fails them. --- .../DependencyAuditTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/ShellUI.Native.Tests/DependencyAuditTests.cs diff --git a/tests/ShellUI.Native.Tests/DependencyAuditTests.cs b/tests/ShellUI.Native.Tests/DependencyAuditTests.cs new file mode 100644 index 0000000..9393ca4 --- /dev/null +++ b/tests/ShellUI.Native.Tests/DependencyAuditTests.cs @@ -0,0 +1,86 @@ +using System.Text.RegularExpressions; +using ShellUI.Native.Core.Models; +using ShellUI.Native.Templates; + +namespace ShellUI.Native.Tests; + +// A component installed on its own has to build, so every type it uses from another template +// must come from its declared dependencies (directly or through them), its family (adding a part +// installs the whole family) or shell (init always installs it). +public class DependencyAuditTests +{ + private static readonly NativePlatform[] Platforms = { NativePlatform.MAUI, NativePlatform.Avalonia }; + + private static readonly Regex Declaration = new( + @"^\s*(?:public|internal)\s+(?:static\s+|sealed\s+|abstract\s+|partial\s+)*(?:class|enum|interface|record|struct)\s+(\w+)", + RegexOptions.Multiline); + + private static readonly Regex CommentsAndStrings = new(@"//[^\n]*|/\*.*?\*/|""(?:[^""\\\n]|\\.)*""", RegexOptions.Singleline); + + // Places where a name can only be a type: new X(…), X name =, , is / as X, (X)cast, : Base, typeof(X). + private static readonly Regex[] TypePositions = + { + new(@"\bnew\s+([A-Z]\w*)\s*[({<]"), + new(@"(?)"), + new(@"<\s*([A-Z]\w*)\s*[>,]"), + new(@",\s*([A-Z]\w*)\s*>"), + new(@"\b(?:is|as)\s+([A-Z]\w*)\b"), + new(@"\(\s*([A-Z]\w*)\s*\)\s*[\w(]"), + new(@"(?:class|record|struct|interface)\s+\w+(?:<[^>]*>)?\s*:\s*([A-Z]\w*)"), + new(@"typeof\(\s*([A-Z]\w*)\s*\)"), + }; + + public static IEnumerable Cases() => + from name in ComponentRegistry.Components.Keys + from platform in Platforms + select new object[] { name, platform }; + + [Theory] + [MemberData(nameof(Cases))] + public void Types_from_other_templates_come_from_dependencies(string name, NativePlatform platform) + { + var owners = TypeOwners(platform); + var allowed = new HashSet { name, "shell" }; + AddClosure(name, allowed); + if (ComponentRegistry.GetFamily(name) is { } family) + { + allowed.Add(family); + AddClosure(family, allowed); + } + + var code = CommentsAndStrings.Replace(ComponentRegistry.GetComponentContent(name, platform) ?? string.Empty, string.Empty); + var missing = TypePositions + .SelectMany(pattern => pattern.Matches(code).Select(m => m.Groups[1].Value)) + .Distinct() + .Where(type => owners.TryGetValue(type, out var from) && !from.Overlaps(allowed)) + .Select(type => $"{type} (from {string.Join(", ", owners[type])})") + .ToList(); + + Assert.True(missing.Count == 0, + $"{platform} '{name}' uses {string.Join("; ", missing)} without depending on it."); + } + + // Which templates declare each type, per platform. + private static Dictionary> TypeOwners(NativePlatform platform) + { + var owners = new Dictionary>(); + foreach (var name in ComponentRegistry.Components.Keys) + { + var content = ComponentRegistry.GetComponentContent(name, platform) ?? string.Empty; + foreach (Match m in Declaration.Matches(content)) + { + if (!owners.TryGetValue(m.Groups[1].Value, out var set)) + owners[m.Groups[1].Value] = set = new HashSet(); + set.Add(name); + } + } + return owners; + } + + private static void AddClosure(string name, HashSet into) + { + foreach (var dependency in ComponentRegistry.GetMetadata(name)?.Dependencies ?? new List()) + if (into.Add(dependency)) + AddClosure(dependency, into); + } +} From fbf8c7868cd08b0904c8701e552e939f628c4c98 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 11:43:53 +0200 Subject: [PATCH 10/11] docs: donut-chart as its own component --- docs/COMPONENTS.md | 4 ++-- docs/DEVELOPMENT_PLAN.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 2edec9f..8b174e7 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -1496,7 +1496,7 @@ they take their colors from the theme: `Chart1` to `Chart5`, in order. Every cha a tooltip under the pointer (or the finger) and a grow-in animation. ```bash -shellui-native add bar-chart line-chart area-chart pie-chart radar-chart radial-chart +shellui-native add bar-chart line-chart area-chart pie-chart donut-chart radar-chart radial-chart ``` Each chart installs `chart`, the shared core. The chart colors are new theme tokens: a project @@ -1555,7 +1555,7 @@ A line per series with the space under it filled. **Properties:** `IsSmooth` (tr ### Pie Chart and Donut Chart The first series' values as slices, clockwise from twelve o'clock, named by `Labels` and colored from the chart colors in order. The tooltip shows a slice's value and its share. `DonutChart` is -a pie with a hole, in the same template (`shellui-native add pie-chart`, or `add donut-chart`). **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees +a pie with a hole, and its own component (`shellui-native add donut-chart`, which brings `pie-chart`). **Properties:** `InnerRadius` (0 for a pie, 0.6 for a donut), `Gap` (degrees between slices; 1), `ShowLabels` (the value outside each slice), `CenterValue`, `CenterLabel` (text in a donut's hole) diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 43d2bfe..198e89c 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -39,8 +39,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#23 merged since (charts in #23) | 114 registry entries (111 CLI targets, 80 families), each with MAUI and Avalonia content | -| `fix/pre-release-issues` | `main` | **Active** | Issues before `0.2.0-alpha.1`: change events report user input only (#24), sheet `MaxPanelWidth` (#26), `ShellLabel` alignment and line break (#27), native components beside ShellUI Blazor (#28), `donut-chart` alias and did-you-mean | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#23 merged since (charts in #23) | 114 registry entries (111 CLI targets, 80 families), each with MAUI and Avalonia content; 115 / 112 / 81 with `donut-chart` on `fix/pre-release-issues` | +| `fix/pre-release-issues` | `main` | **Active** | Issues before `0.2.0-alpha.1`: change events report user input only (#24), sheet `MaxPanelWidth` (#26), `ShellLabel` alignment and line break (#27), native components beside ShellUI Blazor (#28), `donut-chart` as its own component (as in ShellUI), did-you-mean, a dependency audit test | --- From ad0cba681e7baf16aa4092448ee5890cfa19e790 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 15 Oct 2026 15:08:26 +0200 Subject: [PATCH 11/11] chore(demo): drop the status reset that undid code-raised events Filling the samples from code no longer raises change events, so the MAUI demo doesn't need to reset its status line afterwards; the XAML sets the same text. --- examples/MAUI.Demo/MainPage.xaml.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs index 9fb17a6..c935dcf 100644 --- a/examples/MAUI.Demo/MainPage.xaml.cs +++ b/examples/MAUI.Demo/MainPage.xaml.cs @@ -41,9 +41,6 @@ public MainPage() TagList.Children.Add(new Separator()); TagList.Children.Add(new Label { Text = tag, FontSize = 14 }.Token(Label.TextColorProperty, ShellToken.Foreground)); } - - // Filling the samples above raised their change events; start with the hint instead. - Report("Interact with a component to see its events here."); } private void Report(string message) => StatusLabel.Text = message;