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/docs/COMPONENTS.md b/docs/COMPONENTS.md index 60ee962..8b174e7 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 @@ -1478,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 @@ -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, 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) @@ -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..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–#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; 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 | --- 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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; 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/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 diff --git a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs index ad21826..561f3ac 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentInstaller.cs @@ -84,6 +84,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 +107,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; } @@ -176,7 +185,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..4e1a094 100644 --- a/src/ShellUI.Native.CLI/Services/ComponentManager.cs +++ b/src/ShellUI.Native.CLI/Services/ComponentManager.cs @@ -92,7 +92,7 @@ public static void RemoveComponents(string[] components) 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; } @@ -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; } @@ -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/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs index 74d4ead..ece3888 100644 --- a/src/ShellUI.Native.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs @@ -142,6 +142,7 @@ 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) } }; @@ -195,6 +196,41 @@ public static bool Exists(string componentName) return _templates.ContainsKey(componentName.ToLower()); } + // 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 + .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/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/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/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/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/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/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/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/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/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/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) diff --git a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs index 8bb3882..d20d5e5 100644 --- a/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs +++ b/tests/ShellUI.Native.Tests/ComponentRegistryTests.cs @@ -170,4 +170,44 @@ 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("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.True(ComponentRegistry.Exists(name), name); + } + + [Fact] + public void Donut_chart_builds_on_the_pie_chart() + { + Assert.Contains("pie-chart", ComponentRegistry.GetMetadata("donut-chart")!.Dependencies); + Assert.Contains("class DonutChart : PieChart", ComponentRegistry.GetComponentContent("donut-chart", NativePlatform.Avalonia)!); + } + + [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")); + } } 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); + } +} diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index cad5840..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) @@ -231,4 +232,36 @@ 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)!); + } + + // 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); + } + + // 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); + } } 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); + } +}