Skip to content

Commit ddde74d

Browse files
spec(047): Phase 3 batch 11 — Long-tail descriptors
Long-tail triage ports: PipsPager, ListBox, SelectorBar, BreadcrumbBar. Escape-hatched: FrameElement (imperative Navigate API is Mount-only and descriptor builders have no mount-only entry shape), CalendarViewElement (SelectedDates is an IObservableVector requiring per-element ChangeEchoSuppressor tokens that the builders don't express). Documented gaps: - PipsPager fires SelectedIndexChanged as NumberOfPages widens past the default; the descriptor's HandCodedControlled suppression covers its own SelectedPageIndex write but not the prior OneWay NumberOfPages write. Fixture bounds the mount-fire count rather than asserting zero (legacy mount has the same window — it writes NumberOfPages before subscribing). - ListBox / SelectorBar Items reconciliation is non-keyed (full Clear + Add cycle on any sequence delta). Acceptable for short lists; the legacy SelectorBar arm does an in-place prefix patch which the descriptor builders don't yet express. - BreadcrumbBar mirrors the legacy mount + update: both unconditionally rebuild a label list and assign to ItemsSource. - See `docs/specs/tasks/047-extensible-control-model-implementation.md` Batch 11 entry + the deferred follow-ups list for the two escape-hatched controls. Self-test V1 ON/OFF: Desc_ 52/52 pass, 0 failures. Full suite under both V1 ON and V1 OFF: 1002/1002 pass, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d7756a commit ddde74d

9 files changed

Lines changed: 697 additions & 1 deletion

File tree

docs/specs/tasks/047-extensible-control-model-implementation.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,12 +700,66 @@ shrink lands after V1 ships ON by default.
700700
every `Update*` write and the element's default zero values; the
701701
visible output is the same for callers who never set them. No
702702
behavior delta for non-zero callers.
703+
- [x] **Batch 11** — Long-tail triage: `PipsPager`, `ListBox`,
704+
`SelectorBar`, `BreadcrumbBar` ported. `FrameElement` and
705+
`CalendarViewElement` deferred (escape-hatched).
706+
Fixtures: `Desc_PipsPager_MountUpdate`, `Desc_ListBox_MountUpdate`,
707+
`Desc_SelectorBar_MountUpdate`, `Desc_BreadcrumbBar_MountUpdate`
708+
all pass under V1 ON and V1 OFF.
709+
**Ported (4):**
710+
- **PipsPager**`SelectedPageIndex` round-trip via
711+
`.HandCodedControlled` against the new `PipsPagerEventPayload`;
712+
`NumberOfPages` / `WrapMode` / `MaxVisiblePips` /
713+
`PreviousButtonVisibility` / `NextButtonVisibility` as
714+
`.OneWay`. Trampoline gates on `ChangeEchoSuppressor`.
715+
- **ListBox**`Items` (non-keyed Clear+Add cycle on sequence
716+
delta, mirroring `RadioButtonsDescriptor`) + `SelectedIndex`
717+
round-trip. The single `SelectionChanged` trampoline fires
718+
BOTH `OnSelectedIndexChanged` and the multi-select snapshot
719+
`OnSelectionChanged` — matches the legacy arm's twin-invoke
720+
shape, including the `IndexOf`-against-Items snapshot
721+
reconstruction.
722+
- **SelectorBar**`Items` cycle (Text + Icon per item) +
723+
`SelectedIndex` round-trip mapped through `SelectedItem` ref
724+
(SelectorBar exposes `SelectedItem`, not `SelectedIndex`, as
725+
the live property). Item icon resolution reuses
726+
`Reconciler.ResolveIconForDescriptor` via a
727+
`SymbolIconData` wrapper.
728+
- **BreadcrumbBar**`Items``ItemsSource` (label list) +
729+
`ItemClicked` fire-only event. Trampoline maps
730+
`args.Index` back to `el.Items[idx]` per the legacy arm.
731+
**Escape-hatched (2) — documented gaps:**
732+
- **FrameElement**`Navigate(SourcePageType, NavigationParameter)`
733+
is an imperative API call invoked only at Mount time (the
734+
legacy `UpdateFrame` is just `SetElementTag` + `ApplySetters`,
735+
no re-navigate). The descriptor builders don't distinguish
736+
mount-only writes from update writes — a `.OneWay` for
737+
`SourcePageType` would re-Navigate on every update pass.
738+
The 3 events (`Navigated`, `Navigating`, `NavigationFailed`)
739+
could be ported in isolation, but a descriptor that handles
740+
only events while losing the Mount-time navigation would be
741+
a regression vs. V1 OFF. Authors who need declarative `Frame`
742+
stay on V1 OFF; future work is a mount-only entry shape.
743+
- **CalendarViewElement**`SelectedDates` is an
744+
`IObservableVector<DateTimeOffset>` collection that the legacy
745+
arm mutates element-by-element with per-mutation
746+
`ChangeEchoSuppressor.BeginSuppress` tokens
747+
(`UpdateCalendarView``SyncSelectedDates`: a hash-set diff
748+
with one suppress per Add/Remove). The descriptor builders
749+
don't express collection diffs with per-element suppression
750+
— a single `.OneWay` write to `SelectedDates` would either
751+
echo per element or require a custom collection-aware entry
752+
shape. Authors who need declarative multi-date selection stay
753+
on V1 OFF.
703754
- [ ] Batch 3-followup — `NumberBox` (needs Immediate-mode keystroke
704755
handling + `NumberFormatter` reference-equality semantics that the
705756
descriptor builders don't yet express — likely needs a new entry
706757
shape or a `HandCoded*` path). `RichTextBlock` (incremental
707758
paragraph/inline diffing — needs a child-strategy or new entry
708-
shape).
759+
shape). `FrameElement` (needs a Mount-only entry shape for
760+
imperative `Navigate` calls — see Batch 11). `CalendarViewElement`
761+
(needs a collection-diff entry shape with per-element echo
762+
suppression — see Batch 11).
709763

710764
**Carry-forward known defects** (from Phase 1):
711765

src/Reactor/Core/V1Protocol/ControlEventPayloads.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,48 @@ internal sealed class TeachingTipEventPayload
230230
object>? ActionButtonClickTrampoline;
231231
}
232232

233+
/// <summary>Spec 047 §14 Phase 3 batch 11 — PipsPager SelectedPageIndex
234+
/// round-trip payload. Single slot: <c>SelectedIndexChanged</c> is the only
235+
/// event we surface; the descriptor uses <see cref="ChangeEchoSuppressor"/>
236+
/// to drain the programmatic write.</summary>
237+
internal sealed class PipsPagerEventPayload
238+
{
239+
public global::Windows.Foundation.TypedEventHandler<
240+
Microsoft.UI.Xaml.Controls.PipsPager,
241+
Microsoft.UI.Xaml.Controls.PipsPagerSelectedIndexChangedEventArgs>? SelectedIndexChangedTrampoline;
242+
}
243+
244+
/// <summary>Spec 047 §14 Phase 3 batch 11 — ListBox SelectedIndex round-trip
245+
/// payload. The single <c>SelectionChanged</c> trampoline fires BOTH the
246+
/// element's <c>OnSelectedIndexChanged</c> and (if present) the
247+
/// <c>OnSelectionChanged</c> snapshot callback — mirrors the legacy
248+
/// <c>MountListBox</c> arm's twin-invoke shape.</summary>
249+
internal sealed class ListBoxEventPayload
250+
{
251+
public Microsoft.UI.Xaml.Controls.SelectionChangedEventHandler? SelectionChangedTrampoline;
252+
}
253+
254+
/// <summary>Spec 047 §14 Phase 3 batch 11 — SelectorBar SelectedIndex
255+
/// round-trip payload. <c>SelectionChanged</c> trampoline reads the live
256+
/// SelectedItem reference and converts back to the index via
257+
/// <c>Items.IndexOf</c> to feed <c>OnSelectedIndexChanged</c>.</summary>
258+
internal sealed class SelectorBarEventPayload
259+
{
260+
public global::Windows.Foundation.TypedEventHandler<
261+
Microsoft.UI.Xaml.Controls.SelectorBar,
262+
Microsoft.UI.Xaml.Controls.SelectorBarSelectionChangedEventArgs>? SelectionChangedTrampoline;
263+
}
264+
265+
/// <summary>Spec 047 §14 Phase 3 batch 11 — BreadcrumbBar ItemClicked
266+
/// fire-only payload. Trampoline maps <c>args.Index</c> back to the live
267+
/// element's <c>Items[idx]</c> data — mirrors the legacy arm.</summary>
268+
internal sealed class BreadcrumbBarEventPayload
269+
{
270+
public global::Windows.Foundation.TypedEventHandler<
271+
Microsoft.UI.Xaml.Controls.BreadcrumbBar,
272+
Microsoft.UI.Xaml.Controls.BreadcrumbBarItemClickedEventArgs>? ItemClickedTrampoline;
273+
}
274+
233275
/// <summary>
234276
/// Spec 047 §9.2 — open-ended anchor for delegates registered via
235277
/// <see cref="ReactorBinding{TElement}.OnCustomEvent{TArgs}"/>. Holds a
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Linq;
3+
using Microsoft.UI.Xaml;
4+
using Windows.Foundation;
5+
using WinUI = Microsoft.UI.Xaml.Controls;
6+
7+
namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
8+
9+
/// <summary>
10+
/// Spec 047 §14 Phase 3 (batch 11) — descriptor variant of the hand-coded
11+
/// <c>MountBreadcrumbBar</c> / <c>UpdateBreadcrumbBar</c> arms in
12+
/// <see cref="Reconciler"/>.
13+
///
14+
/// <para><b>Coverage:</b>
15+
/// <list type="bullet">
16+
/// <item><c>Items</c> — one-way <c>ItemsSource</c> assignment of a label
17+
/// list. The descriptor rebuilds the label list on each pass and assigns
18+
/// it to <c>ItemsSource</c> (mirrors the legacy mount + update arms,
19+
/// which both unconditionally do the same).</item>
20+
/// <item><c>ItemClicked</c> — <see cref="ControlDescriptor{TElement,TControl}.HandCodedEvent{TPayload,TDelegate}"/>.
21+
/// The trampoline maps <c>args.Index</c> back to the live element's
22+
/// <c>Items[idx]</c> data — matches the legacy hand-coded mapping.</item>
23+
/// </list></para>
24+
/// </summary>
25+
[Experimental("REACTOR_V1_PREVIEW")]
26+
internal static class BreadcrumbBarDescriptor
27+
{
28+
private static readonly TypedEventHandler<WinUI.BreadcrumbBar, WinUI.BreadcrumbBarItemClickedEventArgs>
29+
ItemClickedTrampoline = (s, args) =>
30+
{
31+
var bar = (WinUI.BreadcrumbBar)s!;
32+
if (Reconciler.GetElementTag(bar) is not BreadcrumbBarElement el) return;
33+
if (args.Index >= 0 && args.Index < el.Items.Length)
34+
el.OnItemClicked?.Invoke(el.Items[args.Index]);
35+
};
36+
37+
public static readonly ControlDescriptor<BreadcrumbBarElement, WinUI.BreadcrumbBar> Descriptor =
38+
new ControlDescriptor<BreadcrumbBarElement, WinUI.BreadcrumbBar>
39+
{
40+
Children = new None<BreadcrumbBarElement, WinUI.BreadcrumbBar>(),
41+
GetSetters = static e => e.Setters,
42+
}
43+
.OneWay<BreadcrumbBarItemData[]>(
44+
get: static e => e.Items,
45+
set: static (c, items) => c.ItemsSource = items.Select(i => i.Label).ToList())
46+
.HandCodedEvent<BreadcrumbBarEventPayload,
47+
TypedEventHandler<WinUI.BreadcrumbBar, WinUI.BreadcrumbBarItemClickedEventArgs>>(
48+
subscribe: static (c, h) => c.ItemClicked += h,
49+
callbackPresent: static e => e.OnItemClicked,
50+
trampoline: ItemClickedTrampoline,
51+
slotIsNull: static p => p.ItemClickedTrampoline is null,
52+
setSlot: static (p, h) => p.ItemClickedTrampoline = h);
53+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Microsoft.UI.Xaml;
3+
using WinUI = Microsoft.UI.Xaml.Controls;
4+
5+
namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
6+
7+
/// <summary>
8+
/// Spec 047 §14 Phase 3 (batch 11) — descriptor variant of the hand-coded
9+
/// <c>MountListBox</c> / <c>UpdateListBox</c> arms in
10+
/// <see cref="Reconciler"/>.
11+
///
12+
/// <para><b>Coverage:</b>
13+
/// <list type="bullet">
14+
/// <item><c>Items</c> — one-way Clear + Add cycle gated on a sequence
15+
/// compare (mirrors the legacy <c>StringArrayEquals</c> guard). Same
16+
/// non-keyed shape as <see cref="RadioButtonsDescriptor"/>.</item>
17+
/// <item><c>SelectedIndex</c> — <see cref="ControlDescriptor{TElement,TControl}.HandCodedControlled{TPayload,TValue,TDelegate}"/>
18+
/// with a typed <c>SelectionChanged</c> trampoline. The trampoline fires
19+
/// BOTH <c>OnSelectedIndexChanged</c> and the multi-select snapshot
20+
/// <c>OnSelectionChanged</c> — matches the legacy mount arm's twin-invoke
21+
/// shape.</item>
22+
/// </list></para>
23+
///
24+
/// <para><b>Known gaps vs. hand-coded handler:</b> Items reconciliation is
25+
/// non-keyed (full rebuild on any sequence delta). Acceptable for short
26+
/// ListBoxes (typical 3–15 options).</para>
27+
/// </summary>
28+
[Experimental("REACTOR_V1_PREVIEW")]
29+
internal static class ListBoxDescriptor
30+
{
31+
private static readonly WinUI.SelectionChangedEventHandler SelectionChangedTrampoline = (s, _) =>
32+
{
33+
var lb = (WinUI.ListBox)s!;
34+
if (ChangeEchoSuppressor.ShouldSuppress(lb)) return;
35+
if (Reconciler.GetElementTag(lb) is not ListBoxElement el) return;
36+
el.OnSelectedIndexChanged?.Invoke(lb.SelectedIndex);
37+
if (el.OnSelectionChanged is { } h)
38+
{
39+
var snapshot = new global::System.Collections.Generic.List<int>(lb.SelectedItems.Count);
40+
for (int i = 0; i < lb.SelectedItems.Count; i++)
41+
{
42+
var idx = lb.Items.IndexOf(lb.SelectedItems[i]);
43+
if (idx >= 0) snapshot.Add(idx);
44+
}
45+
h(snapshot);
46+
}
47+
};
48+
49+
public static readonly ControlDescriptor<ListBoxElement, WinUI.ListBox> Descriptor =
50+
new ControlDescriptor<ListBoxElement, WinUI.ListBox>
51+
{
52+
Children = new None<ListBoxElement, WinUI.ListBox>(),
53+
GetSetters = static e => e.Setters,
54+
}
55+
// Items BEFORE SelectedIndex so SelectedIndex lands against the
56+
// populated collection.
57+
.OneWay<string[]>(
58+
get: static e => e.Items,
59+
set: static (c, items) =>
60+
{
61+
if (ListBoxItemsEqual(c.Items, items)) return;
62+
c.Items.Clear();
63+
foreach (var item in items) c.Items.Add(item);
64+
})
65+
.HandCodedControlled<ListBoxEventPayload, int, WinUI.SelectionChangedEventHandler>(
66+
get: static e => e.SelectedIndex,
67+
set: static (c, v) => c.SelectedIndex = v,
68+
readBack: static c => c.SelectedIndex,
69+
subscribe: static (c, h) => c.SelectionChanged += h,
70+
// Gate is "either callback is present" — match the legacy
71+
// mount arm's HasCallbacks semantics so a ListBox with only the
72+
// snapshot subscriber still wires.
73+
callback: static e =>
74+
e.OnSelectedIndexChanged is not null
75+
? e.OnSelectedIndexChanged
76+
: (e.OnSelectionChanged is not null ? _ => { } : null),
77+
trampoline: SelectionChangedTrampoline,
78+
slotIsNull: static p => p.SelectionChangedTrampoline is null,
79+
setSlot: static (p, h) => p.SelectionChangedTrampoline = h);
80+
81+
private static bool ListBoxItemsEqual(
82+
global::Microsoft.UI.Xaml.Controls.ItemCollection existing, string[] incoming)
83+
{
84+
if (existing.Count != incoming.Length) return false;
85+
for (int i = 0; i < incoming.Length; i++)
86+
{
87+
if (!ReferenceEquals(existing[i], incoming[i])
88+
&& existing[i] as string != incoming[i])
89+
return false;
90+
}
91+
return true;
92+
}
93+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Microsoft.UI.Xaml;
3+
using Windows.Foundation;
4+
using WinUI = Microsoft.UI.Xaml.Controls;
5+
6+
namespace Microsoft.UI.Reactor.Core.V1Protocol.Descriptor.Descriptors;
7+
8+
/// <summary>
9+
/// Spec 047 §14 Phase 3 (batch 11) — descriptor variant of the hand-coded
10+
/// <c>MountPipsPager</c> / <c>UpdatePipsPager</c> arms in
11+
/// <see cref="Reconciler"/>. Single-event round-trip on
12+
/// <c>SelectedPageIndex</c>.
13+
///
14+
/// <para><b>Coverage:</b>
15+
/// <list type="bullet">
16+
/// <item><c>SelectedPageIndex</c> — <see cref="ControlDescriptor{TElement,TControl}.HandCodedControlled{TPayload,TValue,TDelegate}"/>
17+
/// with a typed <c>SelectedIndexChanged</c> trampoline gated on
18+
/// <see cref="ChangeEchoSuppressor"/>. Mirrors the legacy guard's
19+
/// programmatic-write semantics.</item>
20+
/// <item><c>NumberOfPages</c>, <c>WrapMode</c>, <c>MaxVisiblePips</c>,
21+
/// <c>PreviousButtonVisibility</c>, <c>NextButtonVisibility</c> — one-way
22+
/// per legacy.</item>
23+
/// </list></para>
24+
/// </summary>
25+
[Experimental("REACTOR_V1_PREVIEW")]
26+
internal static class PipsPagerDescriptor
27+
{
28+
private static readonly TypedEventHandler<WinUI.PipsPager, WinUI.PipsPagerSelectedIndexChangedEventArgs>
29+
SelectedIndexChangedTrampoline = (s, _) =>
30+
{
31+
var p = (WinUI.PipsPager)s!;
32+
if (ChangeEchoSuppressor.ShouldSuppress(p)) return;
33+
(Reconciler.GetElementTag(p) as PipsPagerElement)
34+
?.OnSelectedPageIndexChanged?.Invoke(p.SelectedPageIndex);
35+
};
36+
37+
public static readonly ControlDescriptor<PipsPagerElement, WinUI.PipsPager> Descriptor =
38+
new ControlDescriptor<PipsPagerElement, WinUI.PipsPager>
39+
{
40+
Children = new None<PipsPagerElement, WinUI.PipsPager>(),
41+
GetSetters = static e => e.Setters,
42+
}
43+
// NumberOfPages BEFORE SelectedPageIndex so the index lands against
44+
// the widened range (WinUI clamps SelectedPageIndex against
45+
// NumberOfPages).
46+
.OneWay(
47+
get: static e => e.NumberOfPages,
48+
set: static (c, v) => c.NumberOfPages = v)
49+
.HandCodedControlled<PipsPagerEventPayload, int,
50+
TypedEventHandler<WinUI.PipsPager, WinUI.PipsPagerSelectedIndexChangedEventArgs>>(
51+
get: static e => e.SelectedPageIndex,
52+
set: static (c, v) => c.SelectedPageIndex = v,
53+
readBack: static c => c.SelectedPageIndex,
54+
subscribe: static (c, h) => c.SelectedIndexChanged += h,
55+
callback: static e => e.OnSelectedPageIndexChanged,
56+
trampoline: SelectedIndexChangedTrampoline,
57+
slotIsNull: static p => p.SelectedIndexChangedTrampoline is null,
58+
setSlot: static (p, h) => p.SelectedIndexChangedTrampoline = h)
59+
.OneWay(
60+
get: static e => e.WrapMode,
61+
set: static (c, v) => c.WrapMode = v)
62+
.OneWay(
63+
get: static e => e.MaxVisiblePips,
64+
set: static (c, v) => c.MaxVisiblePips = v)
65+
.OneWay(
66+
get: static e => e.PreviousButtonVisibility,
67+
set: static (c, v) => c.PreviousButtonVisibility = v)
68+
.OneWay(
69+
get: static e => e.NextButtonVisibility,
70+
set: static (c, v) => c.NextButtonVisibility = v);
71+
}

0 commit comments

Comments
 (0)