From 0af24256916337524abc7808616082b533858473 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 17:36:49 -0500 Subject: [PATCH 1/7] Fill MAUI mapper keys: Page, Layout, IndicatorView, DatePicker, TimePicker + TODOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the trivial single-key gaps from `docs/maui-coverage.md`'s "Partial handlers (gap analysis)" section: * `PageHandler.Title`: registered as a no-op (matches stock MAUI's empty `MapTitle`). * `LayoutHandler.ClipsToBounds`: `Modifier.Clip(RectangleShape)` on the outer container when true. * `IndicatorViewHandler.HideSingle` / `MaximumVisible`: collapse the dot strip when count <= 1 and cap rendered dots respectively. * `DatePickerHandler.IsOpen` (two-way) + `CharacterSpacing` on the trigger label. * `TimePickerHandler.IsOpen` (two-way) + `CharacterSpacing`. * `RefreshViewHandler.IsRefreshEnabled`: swallow the pull-down gesture when false. * `ScrollViewHandler.Content`: version-bump slot so content swaps re-walk the child tree. * `RadioButtonHandler.CharacterSpacing`: TextStyle letter-spacing on the sibling label. Explicit `// TODO` hold-backs (left unmapped so the coverage report flags the gap instead of claiming a no-op wire): * `ImageHandler` / `ImageButtonHandler.IsAnimationPlaying` — needs coil-compose. * `SliderHandler.ThumbImageSource` — needs custom-thumb composable. * `RefreshViewHandler.RefreshColor` — needs PullToRefreshBox color facade extension. * `ScrollViewHandler.{Horizontal,Vertical}ScrollBarVisibility` — Compose `Modifier.{vertical,horizontal}Scroll` has no scrollbar visibility hook. * `RadioButtonHandler.{CornerRadius,StrokeColor,StrokeThickness}` — Material 3 RadioButton chrome is fixed. Coverage moves 24/43 -> 29/43 handlers (67.4%), 830/1224 -> 841/1224 keys (68.7%). Five handlers go green (Page, Layout, IndicatorView, DatePicker, TimePicker). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-coverage.md | 111 +++++++----------- .../Handlers/DatePickerHandler.cs | 49 +++++++- .../Handlers/ImageButtonHandler.cs | 4 + .../Handlers/ImageHandler.cs | 8 ++ .../Handlers/IndicatorViewHandler.cs | 36 +++++- .../Handlers/LayoutHandler.cs | 19 ++- .../Handlers/PageHandler.cs | 15 +++ .../Handlers/RadioButtonHandler.cs | 22 ++++ .../Handlers/RefreshViewHandler.cs | 30 ++++- .../Handlers/ScrollViewHandler.cs | 30 ++++- .../Handlers/SliderHandler.cs | 5 + .../Handlers/TimePickerHandler.cs | 43 ++++++- 12 files changed, 284 insertions(+), 88 deletions(-) diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md index a1b3a919..177dcc92 100644 --- a/docs/maui-coverage.md +++ b/docs/maui-coverage.md @@ -1,6 +1,6 @@ # .NET MAUI ⇄ Microsoft.AndroidX.Compose.Maui backend coverage -Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:25 UTC. +Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:35 UTC. Pinned MAUI version: **10.0.20** (from `Directory.Build.targets`). @@ -15,15 +15,15 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, - **Stock MAUI handlers in scope**: 43 - **Handlers we override**: 24 (**55.8%**) -- **Property-mapper keys covered**: 830 / 1224 (**67.8%**) +- **Property-mapper keys covered**: 841 / 1224 (**68.7%**) ### Per-category coverage | Category | Handlers | Keys | | --- | --- | --- | -| **Pages / Navigation** | 1/4 (25%) | 32/130 (25%) | -| **Containers** | 5/5 (100%) | 162/174 (93%) | -| **Leaves** | 18/18 (100%) | 636/695 (92%) | +| **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) | +| **Containers** | 5/5 (100%) | 165/174 (95%) | +| **Leaves** | 18/18 (100%) | 643/695 (93%) | | **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) | | **Shapes** | 0/1 (0%) | 0/41 (0%) | | **App / Window** | 0/2 (0%) | 0/8 (0%) | @@ -37,31 +37,31 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem | --- | --- | --- | --- | --- | --- | | ❌ | `FlyoutViewHandler` | Pages / Navigation | `FlyoutPage` | — | 0 / 35 (0%) | | ❌ | `NavigationViewHandler` | Pages / Navigation | `NavigationPage` | — | 0 / 31 (0%) | -| 🟡 | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 32 / 33 (97%) | +| ✅ | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 33 / 33 (100%) | | ❌ | `TabbedViewHandler` | Pages / Navigation | `TabbedPage` | — | 0 / 31 (0%) | | 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 34 / 40 (85%) | | ✅ | `ContentViewHandler` | Containers | `ContentView`, `IContentView` | `ContentViewHandler` | 32 / 32 (100%) | -| 🟡 | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 31 / 32 (97%) | -| 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 33 / 35 (94%) | -| 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 32 / 35 (91%) | +| ✅ | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 32 / 32 (100%) | +| 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 34 / 35 (97%) | +| 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 33 / 35 (94%) | | ✅ | `ActivityIndicatorHandler` | Leaves | `ActivityIndicator` | `ActivityIndicatorHandler` | 33 / 33 (100%) | | 🟡 | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 33 / 40 (82%) | | ✅ | `CheckBoxHandler` | Leaves | `CheckBox` | `CheckBoxHandler` | 33 / 33 (100%) | -| 🟡 | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 37 / 39 (95%) | +| ✅ | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 39 / 39 (100%) | | 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 38 / 46 (83%) | | 🟡 | `EntryHandler` | Leaves | `Entry` | `EntryHandler` | 38 / 49 (78%) | | 🟡 | `ImageButtonHandler` | Leaves | `ImageButton` | `ImageButtonHandler` | 37 / 38 (97%) | | 🟡 | `ImageHandler` | Leaves | `Image` | `ImageHandler` | 33 / 34 (97%) | -| 🟡 | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 37 / 39 (95%) | +| ✅ | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 39 / 39 (100%) | | 🟡 | `LabelHandler` | Leaves | `Label` | `LabelHandler` | 35 / 40 (88%) | | 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 36 / 41 (88%) | | ✅ | `ProgressBarHandler` | Leaves | `ProgressBar` | `ProgressBarHandler` | 33 / 33 (100%) | -| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 35 / 39 (90%) | +| 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 36 / 39 (92%) | | 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 37 / 47 (79%) | | 🟡 | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 37 / 38 (97%) | | ✅ | `StepperHandler` | Leaves | `Stepper` | `StepperHandler` | 35 / 35 (100%) | | ✅ | `SwitchHandler` | Leaves | `Switch` | `SwitchHandler` | 34 / 34 (100%) | -| 🟡 | `TimePickerHandler` | Leaves | `TimePicker` | `TimePickerHandler` | 35 / 37 (95%) | +| ✅ | `TimePickerHandler` | Leaves | `TimePicker` | `TimePickerHandler` | 37 / 37 (100%) | | ❌ | `MenuBarHandler` | Menus / Toolbar | `MenuBar` | — | n/a | | ❌ | `MenuBarItemHandler` | Menus / Toolbar | `MenuBarItem` | — | n/a | | ❌ | `MenuFlyoutHandler` | Menus / Toolbar | `MenuFlyout` | — | n/a | @@ -119,15 +119,10 @@ dashed stroke patterns on `Border`). - **`BorderHandler`** (85%) — missing: `Shape`, `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit` - **`LabelHandler`** (88%) — missing: `CharacterSpacing`, `LineHeight`, `Padding`, `TextDecorations`, `VerticalTextAlignment` - **`PickerHandler`** (88%) — missing: `CharacterSpacing`, `HorizontalTextAlignment`, `IsOpen`, `Items`, `VerticalTextAlignment` -- **`RadioButtonHandler`** (90%) — missing: `CharacterSpacing`, `CornerRadius`, `StrokeColor`, `StrokeThickness` -- **`ScrollViewHandler`** (91%) — missing: `Content`, `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility` -- **`RefreshViewHandler`** (94%) — missing: `IsRefreshEnabled`, `RefreshColor` -- **`TimePickerHandler`** (95%) — missing: `CharacterSpacing`, `IsOpen` -- **`DatePickerHandler`** (95%) — missing: `CharacterSpacing`, `IsOpen` -- **`IndicatorViewHandler`** (95%) — missing: `HideSingle`, `MaximumVisible` -- **`LayoutHandler`** (97%) — missing: `ClipsToBounds` -- **`PageHandler`** (97%) — missing: `Title` +- **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness` +- **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility` - **`ImageHandler`** (97%) — missing: `IsAnimationPlaying` +- **`RefreshViewHandler`** (97%) — missing: `RefreshColor` - **`ImageButtonHandler`** (97%) — missing: `IsAnimationPlaying` - **`SliderHandler`** (97%) — missing: `ThumbImageSource` @@ -223,13 +218,9 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._ -### 🟡 `PageHandler` — `Page` +### ✅ `PageHandler` — `Page` -Backed by `PageHandler`. **32 / 33 keys (97%)**. - -Missing keys: - -- [ ] `Title` +Backed by `PageHandler`. **33 / 33 keys (100%)**.
All stock keys @@ -259,7 +250,7 @@ Missing keys: - [x] `ScaleY` - [x] `Semantics` - [x] `Shadow` -- [ ] `Title` +- [x] `Title` - [x] `ToolTip` - [x] `Toolbar` - [x] `TranslationX` @@ -418,13 +409,9 @@ Extra keys we map (no stock counterpart):
-### 🟡 `LayoutHandler` — `Layout` - -Backed by `LayoutHandler`. **31 / 32 keys (97%)**. - -Missing keys: +### ✅ `LayoutHandler` — `Layout` -- [ ] `ClipsToBounds` +Backed by `LayoutHandler`. **32 / 32 keys (100%)**. Extra keys we map (no stock counterpart): @@ -440,7 +427,7 @@ Extra keys we map (no stock counterpart): - [x] `Background` - [x] `Border` - [x] `Clip` -- [ ] `ClipsToBounds` +- [x] `ClipsToBounds` - [x] `ContainerView` - [x] `FlowDirection` - [x] `Height` @@ -471,11 +458,10 @@ Extra keys we map (no stock counterpart): ### 🟡 `RefreshViewHandler` — `RefreshView` -Backed by `RefreshViewHandler`. **33 / 35 keys (94%)**. +Backed by `RefreshViewHandler`. **34 / 35 keys (97%)**. Missing keys: -- [ ] `IsRefreshEnabled` - [ ] `RefreshColor`
All stock keys @@ -492,7 +478,7 @@ Missing keys: - [x] `Height` - [x] `InputTransparent` - [x] `IsEnabled` -- [ ] `IsRefreshEnabled` +- [x] `IsRefreshEnabled` - [x] `IsRefreshing` - [x] `MaximumHeight` - [x] `MaximumWidth` @@ -520,11 +506,10 @@ Missing keys: ### 🟡 `ScrollViewHandler` — `ScrollView` -Backed by `ScrollViewHandler`. **32 / 35 keys (91%)**. +Backed by `ScrollViewHandler`. **33 / 35 keys (94%)**. Missing keys: -- [ ] `Content` - [ ] `HorizontalScrollBarVisibility` - [ ] `VerticalScrollBarVisibility` @@ -537,7 +522,7 @@ Missing keys: - [x] `Border` - [x] `Clip` - [x] `ContainerView` -- [ ] `Content` +- [x] `Content` - [x] `FlowDirection` - [x] `Height` - [ ] `HorizontalScrollBarVisibility` @@ -715,14 +700,9 @@ Backed by `CheckBoxHandler`. **33 / 33 keys (100%)**.
-### 🟡 `DatePickerHandler` — `DatePicker` +### ✅ `DatePickerHandler` — `DatePicker` -Backed by `DatePickerHandler`. **37 / 39 keys (95%)**. - -Missing keys: - -- [ ] `CharacterSpacing` -- [ ] `IsOpen` +Backed by `DatePickerHandler`. **39 / 39 keys (100%)**. Extra keys we map (no stock counterpart): @@ -735,7 +715,7 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `Date` @@ -745,7 +725,7 @@ Extra keys we map (no stock counterpart): - [x] `Height` - [x] `InputTransparent` - [x] `IsEnabled` -- [ ] `IsOpen` +- [x] `IsOpen` - [x] `MaximumDate` - [x] `MaximumHeight` - [x] `MaximumWidth` @@ -1016,14 +996,9 @@ Missing keys: -### 🟡 `IndicatorViewHandler` — `IndicatorView` - -Backed by `IndicatorViewHandler`. **37 / 39 keys (95%)**. +### ✅ `IndicatorViewHandler` — `IndicatorView` -Missing keys: - -- [ ] `HideSingle` -- [ ] `MaximumVisible` +Backed by `IndicatorViewHandler`. **39 / 39 keys (100%)**.
All stock keys @@ -1037,14 +1012,14 @@ Missing keys: - [x] `Count` - [x] `FlowDirection` - [x] `Height` -- [ ] `HideSingle` +- [x] `HideSingle` - [x] `IndicatorColor` - [x] `IndicatorSize` - [x] `IndicatorsShape` - [x] `InputTransparent` - [x] `IsEnabled` - [x] `MaximumHeight` -- [ ] `MaximumVisible` +- [x] `MaximumVisible` - [x] `MaximumWidth` - [x] `MinimumHeight` - [x] `MinimumWidth` @@ -1237,11 +1212,10 @@ Backed by `ProgressBarHandler`. **33 / 33 keys (100%)**. ### 🟡 `RadioButtonHandler` — `RadioButton` -Backed by `RadioButtonHandler`. **35 / 39 keys (90%)**. +Backed by `RadioButtonHandler`. **36 / 39 keys (92%)**. Missing keys: -- [ ] `CharacterSpacing` - [ ] `CornerRadius` - [ ] `StrokeColor` - [ ] `StrokeThickness` @@ -1253,7 +1227,7 @@ Missing keys: - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `Content` @@ -1501,14 +1475,9 @@ Backed by `SwitchHandler`. **34 / 34 keys (100%)**.
-### 🟡 `TimePickerHandler` — `TimePicker` +### ✅ `TimePickerHandler` — `TimePicker` -Backed by `TimePickerHandler`. **35 / 37 keys (95%)**. - -Missing keys: - -- [ ] `CharacterSpacing` -- [ ] `IsOpen` +Backed by `TimePickerHandler`. **37 / 37 keys (100%)**. Extra keys we map (no stock counterpart): @@ -1521,7 +1490,7 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `FlowDirection` @@ -1530,7 +1499,7 @@ Extra keys we map (no stock counterpart): - [x] `Height` - [x] `InputTransparent` - [x] `IsEnabled` -- [ ] `IsOpen` +- [x] `IsOpen` - [x] `MaximumHeight` - [x] `MaximumWidth` - [x] `MinimumHeight` diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs index 89b7b06f..16923552 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs @@ -72,8 +72,10 @@ public partial class DatePickerHandler : ComposeElementHandler [nameof(IDatePicker.MinimumDate)] = MapMinimumDate, [nameof(IDatePicker.MaximumDate)] = MapMaximumDate, [nameof(IDatePicker.Format)] = MapFormat, + [nameof(IDatePicker.IsOpen)] = MapIsOpen, [nameof(ITextStyle.TextColor)] = MapTextColor, [nameof(ITextStyle.Font)] = MapFont, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, }; @@ -88,6 +90,7 @@ public partial class DatePickerHandler : ComposeElementHandler readonly MutableState _textColor = new((long?)null); readonly MutableState _fontSize = new((int?)null); readonly MutableState _bold = new(false); + readonly MutableState _letterSpacing = new((float?)null); readonly MutableState _open = new(false); readonly MutableState _fillWidth = new(false); @@ -123,6 +126,7 @@ public override ComposableNode BuildNode(IComposer composer) var packed = _textColor.Value; var size = _fontSize.Value; var bold = _bold.Value; + var spacing = _letterSpacing.Value; var fill = _fillWidth.Value; var isOpen = _open.Value; @@ -150,9 +154,9 @@ public override ComposableNode BuildNode(IComposer composer) ? new DateTime(ticksValue).ToString(format) : "Pick a date"; - var trigger = new ComposeOutlinedButton(onClick: () => _open.Value = true) + var trigger = new ComposeOutlinedButton(onClick: () => SetOpen(virtualView, true)) { - BuildLabel(label, packed, size, bold), + BuildLabel(label, packed, size, bold, spacing), }; // Combines the layout-fill (when set) with the cross-cutting view // properties (Opacity, Translation, Scale, Rotation, IsVisible, @@ -165,7 +169,7 @@ public override ComposableNode BuildNode(IComposer composer) trigger.PrependModifier(outer); var dialog = isOpen - ? new ComposeDatePickerDialog(onDismissRequest: () => _open.Value = false) + ? new ComposeDatePickerDialog(onDismissRequest: () => SetOpen(virtualView, false)) { ConfirmButton = new TextButton(onClick: () => { @@ -186,10 +190,10 @@ public override ComposableNode BuildNode(IComposer composer) if (VirtualView is { } dp) dp.Date = picked; } - _open.Value = false; + SetOpen(virtualView, false); }) { new ComposeText("OK") }, - DismissButton = new TextButton(onClick: () => _open.Value = false) + DismissButton = new TextButton(onClick: () => SetOpen(virtualView, false)) { new ComposeText("Cancel"), }, @@ -228,7 +232,19 @@ public override ComposableNode BuildNode(IComposer composer) }); } - static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold) + /// + /// Toggle the dialog and propagate the new state back to MAUI's + /// two-way binding. The mapper + /// re-fires with the same value and the + /// equality short-circuit breaks the loop. + /// + void SetOpen(IDatePicker dp, bool isOpen) + { + _open.Value = isOpen; + dp.IsOpen = isOpen; + } + + static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold, float? letterSpacing) { var node = new ComposeText(text); if (packed.HasValue) @@ -237,6 +253,8 @@ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold node.FontSize = new Sp(size.Value); if (bold) node.FontWeight = ComposeFontWeight.Bold; + if (letterSpacing.HasValue) + node.LetterSpacing = new Sp(1) * letterSpacing.Value; return node; } @@ -310,6 +328,25 @@ public static void MapFont(DatePickerHandler handler, IDatePicker dp) == Microsoft.Maui.FontWeight.Bold; } + /// + /// Map (em units in MAUI) + /// to the Compose letterSpacing slot in . + /// MAUI emits double; 0 clears the slot (Compose falls back + /// to the Material default). + /// + public static void MapCharacterSpacing(DatePickerHandler handler, IDatePicker dp) => + handler._letterSpacing.Value = dp.CharacterSpacing > 0 ? (float)dp.CharacterSpacing : null; + + /// + /// Map to the Compose dialog's + /// open slot. Setting this property externally pops up (or + /// dismisses) the dialog without a tap on the trigger button — + /// matches stock MAUI's ShowPickerDialog / + /// HidePickerDialog command bridge. + /// + public static void MapIsOpen(DatePickerHandler handler, IDatePicker dp) => + handler._open.Value = dp.IsOpen; + /// /// Map to /// Modifier.fillMaxWidth() when the picker asks to fill its diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs index 42907e2b..8c3849f6 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageButtonHandler.cs @@ -52,6 +52,10 @@ public partial class ImageButtonHandler : ComposeElementHandlerCommand mapper (inherits view-level commands; no extras). diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs index f7a6b16d..e3e61871 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ImageHandler.cs @@ -53,6 +53,14 @@ public partial class ImageHandler : ComposeElementHandler { [nameof(IImageSourcePart.Source)] = MapSource, [nameof(MauiIImage.Aspect)] = MapAspect, + // TODO: IImage.IsAnimationPlaying — Compose's Image composable + // animates GIFs/WebPs only when the painter wraps a Coil + // ImageRequest. ImageSourceLoader currently materialises a + // static BitmapPainter (or PainterResource for packaged + // drawables); routing through coil-compose is a separate + // dependency + handler rewrite. Leaving unmapped so the + // coverage report flags the gap rather than claiming a no-op + // wire. }; /// Command mapper (inherits view-level commands; no extras). diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs index cc042b34..8da9890a 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/IndicatorViewHandler.cs @@ -69,6 +69,8 @@ public partial class IndicatorViewHandler : ComposeElementHandlerCommand mapper (inherits view-level commands; no extras). @@ -84,6 +86,8 @@ public partial class IndicatorViewHandler : ComposeElementHandler _position = new(0); readonly MutableState _indicatorColor = new((long?)null); readonly MutableState _selectedColor = new((long?)null); + readonly MutableState _hideSingle = new(true); + readonly MutableState _maximumVisible = new(int.MaxValue); /// Construct a handler with the default mappers. public IndicatorViewHandler() : base(Mapper, CommandMapper) { } @@ -106,11 +110,26 @@ public override ComposableNode BuildNode(IComposer composer) int count = view.Count; int position = _position.Value; + bool hideSingle = _hideSingle.Value; + int maxVisible = _maximumVisible.Value; double sizeDp = view.IndicatorSize > 0 ? view.IndicatorSize : 6.0; var shape = view.IndicatorsShape == IndicatorShape.Square ? new RoundedCornerShape(0) : new RoundedCornerShape(50); + // HideSingle (MAUI default = true): collapse the strip when + // there's only one carousel page — there's nothing to indicate. + // Returning an empty Row keeps the slot alive so the next + // recompose with count >= 2 patches in dots without + // re-establishing the parent layout. + int visibleCount = hideSingle && count <= 1 ? 0 : count; + // MaximumVisible caps how many dots are rendered (stock MAUI + // truncates from the tail; we mirror that — a 10-page carousel + // with MaximumVisible = 5 shows dots [0..4], the page indicator + // for pages 5-9 disappears). + if (maxVisible > 0) + visibleCount = Math.Min(visibleCount, maxVisible); + long inactiveColor = _indicatorColor.Value ?? ColorMapping.ToPackedLong(Microsoft.Maui.Graphics.Colors.LightGrey) ?? throw new InvalidOperationException("Failed to pack LightGrey color."); @@ -140,7 +159,7 @@ public override ComposableNode BuildNode(IComposer composer) Modifier = Modifier.Companion.ApplyViewProperties(view).ApplySemantics(view), }; - for (int i = 0; i < count; i++) + for (int i = 0; i < visibleCount; i++) { long color = i == position ? activeColor : inactiveColor; row.Add(new Box @@ -178,4 +197,19 @@ public static void MapIndicatorSize(IndicatorViewHandler handler, MauiIndicatorV /// Bump the indicator-shape version slot. public static void MapIndicatorsShape(IndicatorViewHandler handler, MauiIndicatorView _) => handler._shapeVersion.Value++; + + /// + /// Map : when + /// , the dot strip is suppressed while + /// is 0 or 1. + /// + public static void MapHideSingle(IndicatorViewHandler handler, MauiIndicatorView view) => + handler._hideSingle.Value = view.HideSingle; + + /// + /// Map to a hard cap on + /// the number of dots rendered; tail dots beyond the cap are hidden. + /// + public static void MapMaximumVisible(IndicatorViewHandler handler, MauiIndicatorView view) => + handler._maximumVisible.Value = view.MaximumVisible; } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs index 462133fa..2d6f124d 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LayoutHandler.cs @@ -53,6 +53,7 @@ public partial class LayoutHandler : ComposeElementHandler // invalidates the layout's container subtree (Compose // smart-skips siblings whose state is unchanged). ["Children"] = MapChildren, + ["ClipsToBounds"] = MapClipsToBounds, [nameof(IStackLayout.Spacing)] = MapSpacing, [nameof(IPadding.Padding)] = MapPadding, }; @@ -63,6 +64,7 @@ public partial class LayoutHandler : ComposeElementHandler readonly MutableState _childrenVersion = new(0); readonly MutableState _spacing = new(0f); + readonly MutableState _clipsToBounds = new(false); // Thickness is a MAUI struct; not a Java type, primitive, or // Nullable, so MutableState throws // NotSupportedException at construction. Use a version counter @@ -131,10 +133,15 @@ ComposableNode BuildStack(ILayout layout, IMauiContext context, bool vertical) // 1. ApplyViewProperties wraps the OUTER box (Opacity / Scale / // Rotation / Clip / Shadow / IsVisible affect the entire // padded layout including its own background-painting slot). - // 2. Padding goes innermost so the inner content gets the + // 2. ClipsToBounds = true clips children to the layout's + // rectangle (Compose draws children outside their parent's + // bounds by default — this matches MAUI's Layout.IsClippedToBounds). + // 3. Padding goes innermost so the inner content gets the // inset, while alpha / clip / shadow trace the outer box. // PrependModifier replaces, so emit a single chained call. var outer = Modifier.Companion.ApplyViewProperties(layout).ApplyGestures(layout, context).ApplySemantics(layout); + if (_clipsToBounds.Value) + outer = outer.Clip(Shape.Rectangle); if (padding != Thickness.Zero) { outer = outer.Padding( @@ -167,6 +174,16 @@ public static void MapChildren(LayoutHandler handler, ILayout layout) handler._childrenVersion.Value++; } + /// + /// Map to a + /// Modifier.Clip(RectangleShape) on the outer container. + /// When , children that overflow the + /// layout's measured bounds are clipped — matches MAUI's + /// Layout.IsClippedToBounds contract. + /// + public static void MapClipsToBounds(LayoutHandler handler, ILayout layout) => + handler._clipsToBounds.Value = layout.ClipsToBounds; + /// Map to the spacing slot (dp). public static void MapSpacing(LayoutHandler handler, ILayout layout) { diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs index 83608fcb..bc5d875f 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PageHandler.cs @@ -50,6 +50,7 @@ public partial class PageHandler : ViewHandler new PropertyMapper(ViewHandler.ViewMapper) { ["Content"] = MapContent, + ["Title"] = MapTitle, }; /// Command mapper (inherits the base view commands; no extras). @@ -155,4 +156,18 @@ protected override void DisconnectHandler(ComposeView platformView) platformView.DisposeComposition(); base.DisconnectHandler(platformView); } + + /// + /// Map . The Compose + /// handler is registered against (which has + /// no Title); the Activity/Shell action-bar title is owned by + /// the navigation host, not the page view. Stock MAUI's + /// PageHandler.MapTitle is itself a deliberate no-op for the same + /// reason — Shell / NavigationPage push titles down through their own + /// handlers. We register the key purely for parity so MAUI's batched + /// property pipeline doesn't log "missing mapper" warnings. + /// + public static void MapTitle(PageHandler handler, IContentView page) + { + } } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs index 11f89b73..1475203e 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs @@ -60,6 +60,14 @@ public partial class RadioButtonHandler : ComposeElementHandler [nameof(MauiRadioButton.Content)] = MapContent, [nameof(ITextStyle.TextColor)] = MapTextColor, [nameof(ITextStyle.Font)] = MapFont, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + // TODO: IButtonStroke.{StrokeColor, StrokeThickness, + // CornerRadius} — Material 3's RadioButton renders a fixed + // 20dp circle with no public hook for an outer border or + // corner radius (the chrome is wholly owned by the + // composable's internal Canvas drawing). Wiring would + // require a hand-rolled custom radio composable rather than + // a public-facade extension. Out of scope for this PR. }; /// Command mapper (inherits view-level commands; no extras). @@ -71,6 +79,7 @@ public partial class RadioButtonHandler : ComposeElementHandler readonly MutableState _color = new((long?)null); readonly MutableState _fontSize = new((int?)null); readonly MutableState _bold = new(false); + readonly MutableState _letterSpacing = new((float?)null); /// Construct a handler with the default mappers. public RadioButtonHandler() : base(Mapper, CommandMapper) { } @@ -88,6 +97,7 @@ public override ComposableNode BuildNode(IComposer composer) var packed = _color.Value; var size = _fontSize.Value; var bold = _bold.Value; + var spacing = _letterSpacing.Value; var label = _label.Value; var radio = new ComposeRadioButton(selected: _checked.Value, onClick: OnSelected); @@ -105,6 +115,8 @@ public override ComposableNode BuildNode(IComposer composer) text.FontSize = new Sp(size.Value); if (bold) text.FontWeight = ComposeFontWeight.Bold; + if (spacing.HasValue) + text.LetterSpacing = new Sp(1) * spacing.Value; var row = new Row(horizontalArrangement: null, verticalAlignment: Alignment.Vertical.CenterVertically) @@ -165,4 +177,14 @@ public static void MapFont(RadioButtonHandler handler, IRadioButton rb) handler._bold.Value = (font.Weight & Microsoft.Maui.FontWeight.Bold) == Microsoft.Maui.FontWeight.Bold; } + + /// + /// Map to the Compose + /// label's letterSpacing slot (sp). MAUI's value is em-ish; + /// the multiplication operator on packs a + /// fractional Sp without losing the signal that an + /// int-only constructor would round to zero. + /// + public static void MapCharacterSpacing(RadioButtonHandler handler, IRadioButton rb) => + handler._letterSpacing.Value = rb.CharacterSpacing > 0 ? (float)rb.CharacterSpacing : null; } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs index 73865f96..d828aefe 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RefreshViewHandler.cs @@ -54,8 +54,15 @@ public partial class RefreshViewHandler : ComposeElementHandler public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IRefreshView.IsRefreshing)] = MapIsRefreshing, - ["Content"] = MapContent, + [nameof(IRefreshView.IsRefreshing)] = MapIsRefreshing, + [nameof(IRefreshView.IsRefreshEnabled)] = MapIsRefreshEnabled, + ["Content"] = MapContent, + // TODO: IRefreshView.RefreshColor — Compose Material 3's + // PullToRefreshBox doesn't currently expose containerColor / + // contentColor through our facade (the bound Kotlin overload + // takes a PullToRefreshState parameter we don't surface). + // Wiring this needs a PullToRefreshBox facade extension — + // larger surgery than fits this PR. }; /// Command mapper (inherits view-level commands; no extras). @@ -63,6 +70,7 @@ public partial class RefreshViewHandler : ComposeElementHandler new(ViewCommandMapper); readonly MutableState _isRefreshing = new(false); + readonly MutableState _isEnabled = new(true); // Bumped whenever Content swaps so BuildNode reads the live // PresentedContent reference (IView itself doesn't fit in // MutableState; same trick as ContentViewHandler). @@ -87,6 +95,7 @@ public override ComposableNode BuildNode(IComposer composer) var context = MauiContext ?? throw new InvalidOperationException("MauiContext not set on RefreshViewHandler."); + bool isEnabled = _isEnabled.Value; var box = new ComposePullToRefreshBox( isRefreshing: _isRefreshing.Value, onRefresh: () => @@ -100,6 +109,13 @@ public override ComposableNode BuildNode(IComposer composer) // The mapper re-fires with the same value and the // MutableState equality short-circuit breaks the // loop. + // + // When IsRefreshEnabled = false we swallow the pull-down + // gesture entirely — stock MAUI sets SwipeRefreshLayout.Enabled + // = false which suppresses both the spinner and the + // event, matching that behaviour here without needing + // the lower-level enabled-flag API on PullToRefreshBox. + if (!isEnabled) return; _isRefreshing.Value = true; view.IsRefreshing = true; }); @@ -127,4 +143,14 @@ public static void MapIsRefreshing(RefreshViewHandler handler, IRefreshView view /// Bump the content version slot so re-walks. public static void MapContent(RefreshViewHandler handler, IRefreshView _) => handler._contentVersion.Value++; + + /// + /// Map . When + /// the pull-to-refresh gesture is swallowed + /// — the spinner never appears and IsRefreshing = true is + /// not written. Matches MAUI's stock Android behaviour of disabling + /// the underlying SwipeRefreshLayout. + /// + public static void MapIsRefreshEnabled(RefreshViewHandler handler, IRefreshView view) => + handler._isEnabled.Value = view.IsRefreshEnabled; } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs index bb031cf8..59445f61 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ScrollViewHandler.cs @@ -33,6 +33,14 @@ public partial class ScrollViewHandler : ComposeElementHandler new PropertyMapper(ViewHandler.ViewMapper) { [nameof(IScrollView.Orientation)] = MapOrientation, + [nameof(IScrollView.Content)] = MapContent, + // TODO: IScrollView.HorizontalScrollBarVisibility / + // VerticalScrollBarVisibility — Compose's + // Modifier.verticalScroll / horizontalScroll API doesn't + // expose a scrollbar-visibility flag. Suppressing the + // overlay scrollbar requires a hand-built rememberScrollState + // + custom Scrollbar overlay; revisit when porting + // PullToRefresh-style scroll affordances. }; /// Command mapper (inherits view-level commands; no extras). @@ -40,6 +48,10 @@ public partial class ScrollViewHandler : ComposeElementHandler new(ViewCommandMapper); readonly MutableState _orientation = new((int)ScrollOrientation.Vertical); + // Content swaps don't trigger a property changed on Orientation — + // bumping a version slot recomposes BuildNode which re-walks the + // new PresentedContent. + readonly MutableState _contentVersion = new(0); /// Construct a handler with the default mappers. public ScrollViewHandler() : base(Mapper, CommandMapper) { } @@ -56,13 +68,23 @@ public override ComposableNode BuildNode(IComposer composer) var context = MauiContext ?? throw new InvalidOperationException("MauiContext not set on ScrollViewHandler."); - return new ScrollContainer(this, scroll, _orientation, context); + return new ScrollContainer(this, scroll, _orientation, _contentVersion, context); } /// Map to the cached enum slot. public static void MapOrientation(ScrollViewHandler handler, IScrollView view) => handler._orientation.Value = (int)view.Orientation; + /// + /// Bump the content version slot when + /// swaps so the child walker + /// re-renders against the new tree. The new + /// is read live inside + /// ScrollContainer.Render. + /// + public static void MapContent(ScrollViewHandler handler, IScrollView _) => + handler._contentVersion.Value++; + /// /// implementing the /// scroll-wrapped . Pulled out so the @@ -76,17 +98,20 @@ sealed class ScrollContainer : ComposableNode readonly ScrollViewHandler _owner; readonly IScrollView _scroll; readonly MutableState _orientation; + readonly MutableState _contentVersion; readonly IMauiContext _context; public ScrollContainer( ScrollViewHandler owner, IScrollView scroll, MutableState orientation, + MutableState contentVersion, IMauiContext context) { _owner = owner; _scroll = scroll; _orientation = orientation; + _contentVersion = contentVersion; _context = context; } @@ -99,6 +124,9 @@ public override void Render(IComposer composer) _owner.SubscribeToViewProperties(); var orientation = (ScrollOrientation)_orientation.Value; + // Read the content version so swapping ScrollView.Content + // re-runs the walker. Live PresentedContent is read below. + _ = _contentVersion.Value; var state = composer.Remember(() => new ScrollState()); var fillMain = orientation switch diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs index 9386ca0d..ae24de4d 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SliderHandler.cs @@ -54,6 +54,11 @@ public partial class SliderHandler : ComposeElementHandler [nameof(ISlider.MinimumTrackColor)] = MapMinimumTrackColor, [nameof(ISlider.MaximumTrackColor)] = MapMaximumTrackColor, [nameof(ISlider.ThumbColor)] = MapThumbColor, + // TODO: ISlider.ThumbImageSource — Material 3's Slider draws + // its thumb as a fixed circle; supplying a custom drawable + // requires the lower-level Slider(state, ..., thumb = { ... }) + // overload plus piping the resolved Painter through our + // ImageSourceLoader. Larger surgery than fits this PR. }; /// Command mapper (inherits view-level commands; no extras). diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs index 62b74bc9..b4cdf014 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs @@ -53,8 +53,10 @@ public partial class TimePickerHandler : ComposeElementHandler { [nameof(ITimePicker.Time)] = MapTime, [nameof(ITimePicker.Format)] = MapFormat, + [nameof(ITimePicker.IsOpen)] = MapIsOpen, [nameof(ITextStyle.TextColor)] = MapTextColor, [nameof(ITextStyle.Font)] = MapFont, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, }; @@ -67,6 +69,7 @@ public partial class TimePickerHandler : ComposeElementHandler readonly MutableState _textColor = new((long?)null); readonly MutableState _fontSize = new((int?)null); readonly MutableState _bold = new(false); + readonly MutableState _letterSpacing = new((float?)null); readonly MutableState _open = new(false); readonly MutableState _fillWidth = new(false); @@ -90,6 +93,7 @@ public override ComposableNode BuildNode(IComposer composer) var packed = _textColor.Value; var size = _fontSize.Value; var bold = _bold.Value; + var spacing = _letterSpacing.Value; var fill = _fillWidth.Value; var isOpen = _open.Value; @@ -116,9 +120,9 @@ public override ComposableNode BuildNode(IComposer composer) ticks, is24Hour); - var trigger = new ComposeOutlinedButton(onClick: () => _open.Value = true) + var trigger = new ComposeOutlinedButton(onClick: () => SetOpen(virtualView, true)) { - BuildLabel(label, packed, size, bold), + BuildLabel(label, packed, size, bold, spacing), }; // Combines the layout-fill (when set) with the cross-cutting view // properties (Opacity, Translation, Scale, Rotation, IsVisible, @@ -131,7 +135,7 @@ public override ComposableNode BuildNode(IComposer composer) trigger.PrependModifier(outer); var dialog = isOpen - ? new ComposeTimePickerDialog(onDismissRequest: () => _open.Value = false) + ? new ComposeTimePickerDialog(onDismissRequest: () => SetOpen(virtualView, false)) { Title = new ComposeText("Pick a time"), ConfirmButton = new TextButton(onClick: () => @@ -140,10 +144,10 @@ public override ComposableNode BuildNode(IComposer composer) _ticks.Value = picked.Ticks; if (VirtualView is { } tp) tp.Time = picked; - _open.Value = false; + SetOpen(virtualView, false); }) { new ComposeText("OK") }, - DismissButton = new TextButton(onClick: () => _open.Value = false) + DismissButton = new TextButton(onClick: () => SetOpen(virtualView, false)) { new ComposeText("Cancel"), }, @@ -159,7 +163,17 @@ public override ComposableNode BuildNode(IComposer composer) }); } - static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold) + /// + /// Toggle the dialog and propagate the new state back to MAUI's + /// two-way binding. + /// + void SetOpen(ITimePicker tp, bool isOpen) + { + _open.Value = isOpen; + tp.IsOpen = isOpen; + } + + static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold, float? letterSpacing) { var node = new ComposeText(text); if (packed.HasValue) @@ -168,6 +182,8 @@ static ComposableNode BuildLabel(string text, long? packed, int? size, bool bold node.FontSize = new Sp(size.Value); if (bold) node.FontWeight = ComposeFontWeight.Bold; + if (letterSpacing.HasValue) + node.LetterSpacing = new Sp(1) * letterSpacing.Value; return node; } @@ -195,6 +211,21 @@ public static void MapFont(TimePickerHandler handler, ITimePicker tp) == Microsoft.Maui.FontWeight.Bold; } + /// + /// Map to the Compose + /// letterSpacing slot in . + /// + public static void MapCharacterSpacing(TimePickerHandler handler, ITimePicker tp) => + handler._letterSpacing.Value = tp.CharacterSpacing > 0 ? (float)tp.CharacterSpacing : null; + + /// + /// Map to the Compose dialog's + /// open slot — external pushes pop / dismiss the dialog without a + /// trigger tap. + /// + public static void MapIsOpen(TimePickerHandler handler, ITimePicker tp) => + handler._open.Value = tp.IsOpen; + /// /// Map to /// Modifier.fillMaxWidth() when the picker asks to fill its From 6994e0be27faf33f95452e0a08f458921439bf31 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 17:42:34 -0500 Subject: [PATCH 2/7] Wire LabelHandler + PickerHandler mapper keys Label: CharacterSpacing, LineHeight, Padding, TextDecorations. Picker: CharacterSpacing, HorizontalTextAlignment, IsOpen (two-way), Items alias. Both still hold back VerticalTextAlignment (TODO; needs Box contentAlignment). Coverage: 841 -> 849 / 1224 keys (68.7%% -> 69.4%%). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-coverage.md | 42 +++----- .../Handlers/LabelHandler.cs | 101 +++++++++++++++++- .../Handlers/PickerHandler.cs | 79 ++++++++++++-- 3 files changed, 186 insertions(+), 36 deletions(-) diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md index 177dcc92..7f8818e7 100644 --- a/docs/maui-coverage.md +++ b/docs/maui-coverage.md @@ -1,6 +1,6 @@ # .NET MAUI ⇄ Microsoft.AndroidX.Compose.Maui backend coverage -Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:35 UTC. +Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:41 UTC. Pinned MAUI version: **10.0.20** (from `Directory.Build.targets`). @@ -15,7 +15,7 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, - **Stock MAUI handlers in scope**: 43 - **Handlers we override**: 24 (**55.8%**) -- **Property-mapper keys covered**: 841 / 1224 (**68.7%**) +- **Property-mapper keys covered**: 849 / 1224 (**69.4%**) ### Per-category coverage @@ -23,7 +23,7 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, | --- | --- | --- | | **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) | | **Containers** | 5/5 (100%) | 165/174 (95%) | -| **Leaves** | 18/18 (100%) | 643/695 (93%) | +| **Leaves** | 18/18 (100%) | 651/695 (94%) | | **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) | | **Shapes** | 0/1 (0%) | 0/41 (0%) | | **App / Window** | 0/2 (0%) | 0/8 (0%) | @@ -53,8 +53,8 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem | 🟡 | `ImageButtonHandler` | Leaves | `ImageButton` | `ImageButtonHandler` | 37 / 38 (97%) | | 🟡 | `ImageHandler` | Leaves | `Image` | `ImageHandler` | 33 / 34 (97%) | | ✅ | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 39 / 39 (100%) | -| 🟡 | `LabelHandler` | Leaves | `Label` | `LabelHandler` | 35 / 40 (88%) | -| 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 36 / 41 (88%) | +| 🟡 | `LabelHandler` | Leaves | `Label` | `LabelHandler` | 39 / 40 (98%) | +| 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 40 / 41 (98%) | | ✅ | `ProgressBarHandler` | Leaves | `ProgressBar` | `ProgressBarHandler` | 33 / 33 (100%) | | 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 36 / 39 (92%) | | 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 37 / 47 (79%) | @@ -117,14 +117,14 @@ dashed stroke patterns on `Border`). - **`ButtonHandler`** (82%) — missing: `CharacterSpacing`, `CornerRadius`, `Font`, `Padding`, `Source`, `StrokeColor`, `StrokeThickness` - **`EditorHandler`** (83%) — missing: `CharacterSpacing`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `PlaceholderColor`, `SelectionLength`, `VerticalTextAlignment` - **`BorderHandler`** (85%) — missing: `Shape`, `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit` -- **`LabelHandler`** (88%) — missing: `CharacterSpacing`, `LineHeight`, `Padding`, `TextDecorations`, `VerticalTextAlignment` -- **`PickerHandler`** (88%) — missing: `CharacterSpacing`, `HorizontalTextAlignment`, `IsOpen`, `Items`, `VerticalTextAlignment` - **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness` - **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility` - **`ImageHandler`** (97%) — missing: `IsAnimationPlaying` - **`RefreshViewHandler`** (97%) — missing: `RefreshColor` - **`ImageButtonHandler`** (97%) — missing: `IsAnimationPlaying` - **`SliderHandler`** (97%) — missing: `ThumbImageSource` +- **`LabelHandler`** (98%) — missing: `VerticalTextAlignment` +- **`PickerHandler`** (98%) — missing: `VerticalTextAlignment` ## Per-handler property detail @@ -1046,14 +1046,10 @@ Backed by `IndicatorViewHandler`. **39 / 39 keys (100%)**. ### 🟡 `LabelHandler` — `Label` -Backed by `LabelHandler`. **35 / 40 keys (88%)**. +Backed by `LabelHandler`. **39 / 40 keys (98%)**. Missing keys: -- [ ] `CharacterSpacing` -- [ ] `LineHeight` -- [ ] `Padding` -- [ ] `TextDecorations` - [ ] `VerticalTextAlignment` Extra keys we map (no stock counterpart): @@ -1067,7 +1063,7 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `FlowDirection` @@ -1076,13 +1072,13 @@ Extra keys we map (no stock counterpart): - [x] `HorizontalTextAlignment` - [x] `InputTransparent` - [x] `IsEnabled` -- [ ] `LineHeight` +- [x] `LineHeight` - [x] `MaximumHeight` - [x] `MaximumWidth` - [x] `MinimumHeight` - [x] `MinimumWidth` - [x] `Opacity` -- [ ] `Padding` +- [x] `Padding` - [x] `Rotation` - [x] `RotationX` - [x] `RotationY` @@ -1094,7 +1090,7 @@ Extra keys we map (no stock counterpart): - [x] `Shadow` - [x] `Text` - [x] `TextColor` -- [ ] `TextDecorations` +- [x] `TextDecorations` - [x] `ToolTip` - [x] `Toolbar` - [x] `TranslationX` @@ -1107,14 +1103,10 @@ Extra keys we map (no stock counterpart): ### 🟡 `PickerHandler` — `Picker` -Backed by `PickerHandler`. **36 / 41 keys (88%)**. +Backed by `PickerHandler`. **40 / 41 keys (98%)**. Missing keys: -- [ ] `CharacterSpacing` -- [ ] `HorizontalTextAlignment` -- [ ] `IsOpen` -- [ ] `Items` - [ ] `VerticalTextAlignment` Extra keys we map (no stock counterpart): @@ -1129,17 +1121,17 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `FlowDirection` - [x] `Font` - [x] `Height` -- [ ] `HorizontalTextAlignment` +- [x] `HorizontalTextAlignment` - [x] `InputTransparent` - [x] `IsEnabled` -- [ ] `IsOpen` -- [ ] `Items` +- [x] `IsOpen` +- [x] `Items` - [x] `MaximumHeight` - [x] `MaximumWidth` - [x] `MinimumHeight` diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs index 68811c71..1093e76c 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs @@ -40,11 +40,20 @@ public partial class LabelHandler : ComposeElementHandler public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IText.Text)] = MapText, - [nameof(ITextStyle.TextColor)] = MapTextColor, - [nameof(ITextStyle.Font)] = MapFont, - [nameof(ILabel.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + [nameof(IText.Text)] = MapText, + [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + [nameof(ITextStyle.Font)] = MapFont, + [nameof(ILabel.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + [nameof(ILabel.LineHeight)] = MapLineHeight, + [nameof(ILabel.TextDecorations)] = MapTextDecorations, + [nameof(IPadding.Padding)] = MapPadding, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + // TODO: VerticalTextAlignment — requires wrapping the Text in a + // Box whose `contentAlignment` slot is set; our Box facade + // currently doesn't expose `contentAlignment` so we can't drive + // it without a generator change. In stock MAUI this only has a + // visible effect when the Label has an explicit Height anyway. }; /// Command mapper (inherits view-level commands; no extras). @@ -60,6 +69,19 @@ public partial class LabelHandler : ComposeElementHandler // user-defined enums and would throw NotSupportedException at ctor time. readonly MutableState _hTextAlign = new((int)TextAlignment.Start); readonly MutableState _fillWidth = new(false); + // CharacterSpacing in MAUI is "em"-ish (0..1 typically). Packed via the + // Sp(1) * float overload because Sp has no (float) ctor. + readonly MutableState _letterSpacing = new((float?)null); + // LineHeight in MAUI is a multiplier on the default line height; we + // expose it as an int sp here for simplicity — null when not set. + readonly MutableState _lineHeight = new((int?)null); + // TextDecorations enum stored as int (Flags: None=0, Underline=1, + // Strikethrough=2). MutableState's generic boxed path doesn't recognise + // [Flags] enums. + readonly MutableState _decorations = new(0); + // Padding live-read in BuildNode; this version slot just bumps to force + // a recomposition when MAUI invokes the mapper. + readonly MutableState _paddingVersion = new(0); /// Construct a handler with the default mappers. public LabelHandler() : base(Mapper, CommandMapper) { } @@ -85,6 +107,12 @@ public override ComposableNode BuildNode(IComposer composer) var bold = _bold.Value; var fill = _fillWidth.Value; var align = (TextAlignment)_hTextAlign.Value; + var letterSpacing = _letterSpacing.Value; + var lineHeight = _lineHeight.Value; + var decorations = (Microsoft.Maui.TextDecorations)_decorations.Value; + // Subscribe so padding mapper bumps re-run BuildNode. + _ = _paddingVersion.Value; + var padding = virtualView.Padding; var text = new ComposeText(_text.Value) { Color = packed.HasValue ? new ComposeColor(packed.Value) : null, @@ -96,12 +124,34 @@ public override ComposableNode BuildNode(IComposer composer) TextAlignment.End => ComposeTextAlign.End, _ => null, }, + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + LineHeight = lineHeight.HasValue ? new Sp(lineHeight.Value) : null, + // Strikethrough takes precedence over Underline when both bits + // are set; combining the two would need TextDecoration.Combine + // which isn't bound yet. + // TODO: expose TextDecoration.Combine to handle the + // Underline | Strikethrough combination faithfully. + Decoration = decorations switch + { + Microsoft.Maui.TextDecorations.Strikethrough => TextDecoration.LineThrough, + Microsoft.Maui.TextDecorations.Underline => TextDecoration.Underline, + _ when (decorations & Microsoft.Maui.TextDecorations.Strikethrough) != 0 + => TextDecoration.LineThrough, + _ when (decorations & Microsoft.Maui.TextDecorations.Underline) != 0 + => TextDecoration.Underline, + _ => null, + }, }; // Single PrependModifier call combining the layout-fill (if // applicable) with the cross-cutting view properties — calling // PrependModifier twice would replace, not merge, so this // builds the chain once. var outer = (fill ? Modifier.FillMaxWidth() : Modifier.Companion) + .Padding( + new Dp((float)padding.Left), + new Dp((float)padding.Top), + new Dp((float)padding.Right), + new Dp((float)padding.Bottom)) .ApplyViewProperties(virtualView) .ApplyGestures(virtualView, MauiContext) .ApplySemantics(virtualView); @@ -149,4 +199,47 @@ public static void MapHorizontalLayoutAlignment(LabelHandler handler, ILabel lab is Microsoft.Maui.Primitives.LayoutAlignment.Fill or Microsoft.Maui.Primitives.LayoutAlignment.Center; + /// + /// Map to Compose + /// letterSpacing. MAUI's value is in "em-ish" units; we + /// pack it via the * float overload because + /// has no (float) constructor. + /// + public static void MapCharacterSpacing(LabelHandler handler, ILabel label) => + handler._letterSpacing.Value = label.CharacterSpacing != 0 + ? (float)label.CharacterSpacing + : null; + + /// Map to Compose lineHeight. + /// + /// MAUI's LineHeight is a multiplier on the platform default + /// line height (-1 = use default). Compose expects an absolute sp. + /// We approximate by multiplying against the current font size when + /// known; otherwise leave the slot unset. + /// + public static void MapLineHeight(LabelHandler handler, ILabel label) + { + var lh = label.LineHeight; + if (lh <= 0) + { + handler._lineHeight.Value = null; + return; + } + var size = label.Font.Size > 0 ? label.Font.Size : 14d; + handler._lineHeight.Value = (int)Math.Round(size * lh); + } + + /// + /// Map to Compose + /// TextDecoration. Only single-flag values render correctly; + /// the combined Underline | Strikethrough case falls back to + /// Strikethrough (see ). + /// + public static void MapTextDecorations(LabelHandler handler, ILabel label) => + handler._decorations.Value = (int)label.TextDecorations; + + /// Map . Live-read in . + public static void MapPadding(LabelHandler handler, ILabel label) => + handler._paddingVersion.Value = handler._paddingVersion.Value + 1; + } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs index 86207a83..a8693951 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/PickerHandler.cs @@ -8,6 +8,7 @@ using ComposeFontWeight = AndroidX.Compose.FontWeight; using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField; using ComposeText = AndroidX.Compose.Text; +using ComposeTextAlign = AndroidX.Compose.TextAlign; using ComposeTextStyle = AndroidX.Compose.TextStyle; using MauiPicker = Microsoft.Maui.Controls.Picker; @@ -64,12 +65,22 @@ public partial class PickerHandler : ComposeElementHandler // the dropdown menu's child list (Compose smart-skips // siblings whose state is unchanged). ["ItemsSource"] = MapItemsSource, + // Stock MAUI mapper also exposes an "Items" key that fires + // when the IPicker.Items collection is reset; we forward + // to the same handler. + ["Items"] = MapItemsSource, [nameof(IPicker.SelectedIndex)] = MapSelectedIndex, [nameof(IPicker.Title)] = MapTitle, [nameof(IPicker.TitleColor)] = MapTitleColor, [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, [nameof(ITextStyle.Font)] = MapFont, + [nameof(IPicker.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + [nameof(IPicker.IsOpen)] = MapIsOpen, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + // TODO: VerticalTextAlignment — see LabelHandler. Requires a + // Box wrapper with `contentAlignment` to take visible effect + // (and only matters when the picker has an explicit Height). }; /// Command mapper (inherits view-level commands; no extras). @@ -85,6 +96,9 @@ public partial class PickerHandler : ComposeElementHandler readonly MutableState _bold = new(false); readonly MutableState _open = new(false); readonly MutableState _fillWidth = new(false); + readonly MutableState _letterSpacing = new((float?)null); + // Stored as the underlying int (see LabelHandler note on enum-backed slots). + readonly MutableState _hTextAlign = new((int)TextAlignment.Start); INotifyCollectionChanged? _subscribedItems; @@ -143,6 +157,8 @@ public override ComposableNode BuildNode(IComposer composer) var bold = _bold.Value; var fill = _fillWidth.Value; var isOpen = _open.Value; + var letterSpacing = _letterSpacing.Value; + var hAlign = (TextAlignment)_hTextAlign.Value; var displayValue = selectedIndex >= 0 && selectedIndex < items.Count ? items[selectedIndex] ?? string.Empty @@ -152,7 +168,7 @@ public override ComposableNode BuildNode(IComposer composer) { ReadOnly = true, SingleLine = true, - TrailingIcon = new IconButton(onClick: () => _open.Value = !_open.Value) + TrailingIcon = new IconButton(onClick: () => SetOpen(virtualView, !_open.Value)) { new ComposeText(isOpen ? "▲" : "▼"), }, @@ -163,13 +179,20 @@ public override ComposableNode BuildNode(IComposer composer) ? new ComposeText(title) { Color = new ComposeColor(packedTitleColor.Value) } : new ComposeText(title); } - if (packedTextColor.HasValue || size.HasValue || bold) + if (packedTextColor.HasValue || size.HasValue || bold || letterSpacing.HasValue || hAlign != TextAlignment.Start) { trigger.TextStyle = new ComposeTextStyle { - Color = packedTextColor.HasValue ? new ComposeColor(packedTextColor.Value) : null, - FontSize = size.HasValue ? new Sp(size.Value) : null, - FontWeight = bold ? ComposeFontWeight.Bold : null, + Color = packedTextColor.HasValue ? new ComposeColor(packedTextColor.Value) : null, + FontSize = size.HasValue ? new Sp(size.Value) : null, + FontWeight = bold ? ComposeFontWeight.Bold : null, + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + TextAlign = hAlign switch + { + TextAlignment.Center => ComposeTextAlign.Center, + TextAlignment.End => ComposeTextAlign.End, + _ => null, + }, }; } // Combines the layout-fill (when set) with the cross-cutting view @@ -185,7 +208,7 @@ public override ComposableNode BuildNode(IComposer composer) var menu = new ExposedDropdownMenu( expanded: isOpen, - onDismissRequest: () => _open.Value = false); + onDismissRequest: () => SetOpen(virtualView, false)); for (int i = 0; i < items.Count; i++) { // Capture i so the click closure points at this row's index. @@ -198,13 +221,28 @@ public override ComposableNode BuildNode(IComposer composer) return new ExposedDropdownMenuBox( expanded: isOpen, - onExpandedChange: v => _open.Value = v) + onExpandedChange: v => SetOpen(virtualView, v)) { trigger, menu, }; } + // Two-way IsOpen helper: writes both the Compose state slot and the + // MAUI virtual view in lockstep. MutableState's equality short-circuit + // breaks the resulting MapIsOpen feedback loop just like + // EntryHandler.OnValueChanged. + static void SetOpen(IPicker picker, bool isOpen, PickerHandler? handler = null) + { + // Best-effort lookup of the handler when caller didn't pass it. + // Avoids holding a strong field reference inside lambdas. + handler ??= picker.Handler as PickerHandler; + if (handler is not null) + handler._open.Value = isOpen; + if (picker.IsOpen != isOpen) + picker.IsOpen = isOpen; + } + void OnItemSelected(int index) { // Update Compose state synchronously so the trigger label @@ -295,4 +333,31 @@ public static void MapFont(PickerHandler handler, IPicker picker) public static void MapHorizontalLayoutAlignment(PickerHandler handler, IPicker picker) => handler._fillWidth.Value = picker.HorizontalLayoutAlignment == Microsoft.Maui.Primitives.LayoutAlignment.Fill; + + /// + /// Map to the Compose + /// TextStyle.LetterSpacing slot on the trigger. + /// + public static void MapCharacterSpacing(PickerHandler handler, IPicker picker) => + handler._letterSpacing.Value = picker.CharacterSpacing != 0 + ? (float)picker.CharacterSpacing + : null; + + /// + /// Map to the Compose + /// TextStyle.TextAlign slot on the trigger. + /// + public static void MapHorizontalTextAlignment(PickerHandler handler, IPicker picker) => + handler._hTextAlign.Value = (int)picker.HorizontalTextAlignment; + + /// + /// Map to the Compose dropdown + /// expansion state. Two-way: tap/dismiss both write the virtual + /// view's IsOpen back through . + /// + public static void MapIsOpen(PickerHandler handler, IPicker picker) + { + if (handler._open.Value != picker.IsOpen) + handler._open.Value = picker.IsOpen; + } } From 8b008014591c393d92fd6d3c05c4cda7a936ea4e Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 17:45:32 -0500 Subject: [PATCH 3/7] Wire BorderHandler.Shape alias + ButtonHandler full mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Button: CharacterSpacing, CornerRadius, Font, Padding, Source (via ImageSourceLoader), StrokeColor, StrokeThickness. Now 100%% (40/40). Border: Shape alias for the abstract IBorderStroke.Shape key. The 5 dashed-stroke / cap / join / miter keys stay TODO — Compose's Modifier.Border is solid-only and faithful dashed strokes need a custom drawWithCache shader (much larger refactor). Coverage: 849 -> 857 / 1224 keys (69.4%% -> 70.0%%). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-coverage.md | 48 ++--- .../Handlers/BorderHandler.cs | 11 ++ .../Handlers/ButtonHandler.cs | 164 +++++++++++++++++- 3 files changed, 184 insertions(+), 39 deletions(-) diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md index 7f8818e7..99dd5d64 100644 --- a/docs/maui-coverage.md +++ b/docs/maui-coverage.md @@ -1,6 +1,6 @@ # .NET MAUI ⇄ Microsoft.AndroidX.Compose.Maui backend coverage -Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:41 UTC. +Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:45 UTC. Pinned MAUI version: **10.0.20** (from `Directory.Build.targets`). @@ -15,15 +15,15 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, - **Stock MAUI handlers in scope**: 43 - **Handlers we override**: 24 (**55.8%**) -- **Property-mapper keys covered**: 849 / 1224 (**69.4%**) +- **Property-mapper keys covered**: 857 / 1224 (**70.0%**) ### Per-category coverage | Category | Handlers | Keys | | --- | --- | --- | | **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) | -| **Containers** | 5/5 (100%) | 165/174 (95%) | -| **Leaves** | 18/18 (100%) | 651/695 (94%) | +| **Containers** | 5/5 (100%) | 166/174 (95%) | +| **Leaves** | 18/18 (100%) | 658/695 (95%) | | **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) | | **Shapes** | 0/1 (0%) | 0/41 (0%) | | **App / Window** | 0/2 (0%) | 0/8 (0%) | @@ -39,13 +39,13 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem | ❌ | `NavigationViewHandler` | Pages / Navigation | `NavigationPage` | — | 0 / 31 (0%) | | ✅ | `PageHandler` | Pages / Navigation | `Page` | `PageHandler` | 33 / 33 (100%) | | ❌ | `TabbedViewHandler` | Pages / Navigation | `TabbedPage` | — | 0 / 31 (0%) | -| 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 34 / 40 (85%) | +| 🟡 | `BorderHandler` | Containers | `Border` | `BorderHandler` | 35 / 40 (88%) | | ✅ | `ContentViewHandler` | Containers | `ContentView`, `IContentView` | `ContentViewHandler` | 32 / 32 (100%) | | ✅ | `LayoutHandler` | Containers | `Layout` | `LayoutHandler` | 32 / 32 (100%) | | 🟡 | `RefreshViewHandler` | Containers | `RefreshView` | `RefreshViewHandler` | 34 / 35 (97%) | | 🟡 | `ScrollViewHandler` | Containers | `ScrollView` | `ScrollViewHandler` | 33 / 35 (94%) | | ✅ | `ActivityIndicatorHandler` | Leaves | `ActivityIndicator` | `ActivityIndicatorHandler` | 33 / 33 (100%) | -| 🟡 | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 33 / 40 (82%) | +| ✅ | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 40 / 40 (100%) | | ✅ | `CheckBoxHandler` | Leaves | `CheckBox` | `CheckBoxHandler` | 33 / 33 (100%) | | ✅ | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 39 / 39 (100%) | | 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 38 / 46 (83%) | @@ -114,9 +114,8 @@ dashed stroke patterns on `Border`). - **`EntryHandler`** (78%) — missing: `CharacterSpacing`, `ClearButtonVisibility`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SelectionLength`, `VerticalTextAlignment` - **`SearchBarHandler`** (79%) — missing: `CancelButtonColor`, `CharacterSpacing`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SearchIconColor`, `VerticalTextAlignment` -- **`ButtonHandler`** (82%) — missing: `CharacterSpacing`, `CornerRadius`, `Font`, `Padding`, `Source`, `StrokeColor`, `StrokeThickness` - **`EditorHandler`** (83%) — missing: `CharacterSpacing`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `PlaceholderColor`, `SelectionLength`, `VerticalTextAlignment` -- **`BorderHandler`** (85%) — missing: `Shape`, `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit` +- **`BorderHandler`** (88%) — missing: `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit` - **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness` - **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility` - **`ImageHandler`** (97%) — missing: `IsAnimationPlaying` @@ -302,11 +301,10 @@ _No Compose backend handler. Stock MAUI handler keeps the AppCompat backend._ ### 🟡 `BorderHandler` — `Border` -Backed by `BorderHandler`. **34 / 40 keys (85%)**. +Backed by `BorderHandler`. **35 / 40 keys (88%)**. Missing keys: -- [ ] `Shape` - [ ] `StrokeDashOffset` - [ ] `StrokeDashPattern` - [ ] `StrokeLineCap` @@ -346,7 +344,7 @@ Extra keys we map (no stock counterpart): - [x] `ScaleY` - [x] `Semantics` - [x] `Shadow` -- [ ] `Shape` +- [x] `Shape` - [x] `Stroke` - [ ] `StrokeDashOffset` - [ ] `StrokeDashPattern` @@ -595,19 +593,9 @@ Backed by `ActivityIndicatorHandler`. **33 / 33 keys (100%)**. -### 🟡 `ButtonHandler` — `Button` - -Backed by `ButtonHandler`. **33 / 40 keys (82%)**. +### ✅ `ButtonHandler` — `Button` -Missing keys: - -- [ ] `CharacterSpacing` -- [ ] `CornerRadius` -- [ ] `Font` -- [ ] `Padding` -- [ ] `Source` -- [ ] `StrokeColor` -- [ ] `StrokeThickness` +Backed by `ButtonHandler`. **40 / 40 keys (100%)**. Extra keys we map (no stock counterpart): @@ -620,12 +608,12 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` -- [ ] `CornerRadius` +- [x] `CornerRadius` - [x] `FlowDirection` -- [ ] `Font` +- [x] `Font` - [x] `Height` - [x] `InputTransparent` - [x] `IsEnabled` @@ -634,7 +622,7 @@ Extra keys we map (no stock counterpart): - [x] `MinimumHeight` - [x] `MinimumWidth` - [x] `Opacity` -- [ ] `Padding` +- [x] `Padding` - [x] `Rotation` - [x] `RotationX` - [x] `RotationY` @@ -644,9 +632,9 @@ Extra keys we map (no stock counterpart): - [x] `ScaleY` - [x] `Semantics` - [x] `Shadow` -- [ ] `Source` -- [ ] `StrokeColor` -- [ ] `StrokeThickness` +- [x] `Source` +- [x] `StrokeColor` +- [x] `StrokeThickness` - [x] `Text` - [x] `TextColor` - [x] `ToolTip` diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs index 4e2bc966..0c2dccd6 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/BorderHandler.cs @@ -45,9 +45,20 @@ public partial class BorderHandler : ComposeElementHandler [nameof(MauiBorder.Stroke)] = MapStroke, [nameof(MauiBorder.StrokeThickness)] = MapStrokeThickness, [nameof(MauiBorder.StrokeShape)] = MapShape, + // Stock MAUI exposes the abstract IBorderStroke.Shape key as + // a separate string. Aliased here so MAUI's PropertyMapper + // looks up "Shape" and routes to the same handler. + ["Shape"] = MapShape, ["Content"] = MapContent, [nameof(IPadding.Padding)] = MapPadding, [nameof(IView.Background)] = MapBackground, + // TODO: dashed-stroke / line-cap / line-join / miter-limit + // properties have no Compose equivalent on + // `Modifier.Border`, which only paints a solid border. A + // faithful implementation needs a custom `Modifier.drawWithCache` + // shader, which is a much larger refactor. + // Stock MAUI keys: StrokeDashOffset, StrokeDashPattern, + // StrokeLineCap, StrokeLineJoin, StrokeMiterLimit. }; /// Command mapper (inherits view-level commands; no extras). diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs index 0ecfe158..3303d11d 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/ButtonHandler.cs @@ -1,9 +1,15 @@ using AndroidX.Compose; using AndroidX.Compose.Material3; using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Platform; +using Microsoft.AndroidX.Compose.Maui.Loaders; using Microsoft.AndroidX.Compose.Maui.Platform; using Microsoft.Maui.Handlers; -using ComposeButton = AndroidX.Compose.Button; +using ComposeButton = AndroidX.Compose.Button; +using ComposeColor = AndroidX.Compose.Color; +using ComposeImage = AndroidX.Compose.Image; +using ComposeFontWeight = AndroidX.Compose.FontWeight; +using ComposeText = AndroidX.Compose.Text; namespace Microsoft.AndroidX.Compose.Maui.Handlers; @@ -47,6 +53,13 @@ public partial class ButtonHandler : ComposeElementHandler { [nameof(IText.Text)] = MapText, [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + [nameof(ITextStyle.Font)] = MapFont, + [nameof(IButtonStroke.CornerRadius)] = MapCornerRadius, + [nameof(IButtonStroke.StrokeColor)] = MapStrokeColor, + [nameof(IButtonStroke.StrokeThickness)] = MapStrokeThickness, + [nameof(IImageSourcePart.Source)] = MapImageSource, + [nameof(IPadding.Padding)] = MapPadding, [nameof(IView.Background)] = MapBackground, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, }; @@ -59,6 +72,16 @@ public partial class ButtonHandler : ComposeElementHandler readonly MutableState _containerColor = new((long?)null); readonly MutableState _contentColor = new((long?)null); readonly MutableState _fillWidth = new(false); + readonly MutableState _letterSpacing = new((float?)null); + readonly MutableState _fontSize = new((int?)null); + readonly MutableState _bold = new(false); + // CornerRadius in MAUI IButtonStroke is an int (DIPs). + readonly MutableState _cornerRadius = new(-1); + readonly MutableState _strokeColor = new((long?)null); + readonly MutableState _strokeThickness = new(0f); + // Padding lives as a struct (Thickness); bump on change and live-read. + readonly MutableState _paddingVersion = new(0); + ImageSourceLoader? _loader; /// Construct a handler with the default mappers. public ButtonHandler() : base(Mapper, CommandMapper) { } @@ -67,6 +90,12 @@ public ButtonHandler() : base(Mapper, CommandMapper) { } public ButtonHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null) : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { } + // Lazy — buttons without an ImageSource never allocate the loader. + ImageSourceLoader Loader => + _loader ??= new ImageSourceLoader( + this, + () => (VirtualView as Microsoft.Maui.IImage) as IImageSourcePart); + /// public override ComposableNode BuildNode(IComposer composer) { @@ -75,20 +104,58 @@ public override ComposableNode BuildNode(IComposer composer) SubscribeToViewProperties(); - var container = _containerColor.Value; - var content = _contentColor.Value; - var button = new ComposeButton(onClick: OnClicked) + // Subscribe so padding map bumps re-run BuildNode. + _ = _paddingVersion.Value; + + var container = _containerColor.Value; + var content = _contentColor.Value; + var letterSpacing = _letterSpacing.Value; + var fontSize = _fontSize.Value; + var bold = _bold.Value; + var cornerRadius = _cornerRadius.Value; + var strokeColor = _strokeColor.Value; + var strokeThickness = _strokeThickness.Value; + var padding = (virtualView as IPadding)?.Padding ?? Thickness.Zero; + var hasCustomText = letterSpacing.HasValue || fontSize.HasValue || bold; + + var button = new ComposeButton(onClick: OnClicked); + // Optional leading image — only added when ImageSource resolved. + if (_loader is { } loader) { - new Text(_text.Value), + if (loader.Painter.Value is { } painter) + button.Add(new ComposeImage(painter)); + else if (loader.DrawableResourceId.Value is int id) + button.Add(new ComposeImage(id)); + } + var textNode = new ComposeText(_text.Value) + { + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + FontSize = fontSize.HasValue ? new Sp(fontSize.Value) : null, + FontWeight = bold ? ComposeFontWeight.Bold : null, }; + button.Add(textNode); + if (container is not null || content is not null) button.Colors = composer.ButtonColors( containerColor: container, contentColor: content); - // Single chained PrependModifier — combines the layout-fill - // (when set) with the cross-cutting view properties (Opacity, - // Translation, Scale, Rotation, IsVisible, Clip, Shadow). - var outer = (_fillWidth.Value ? Modifier.FillMaxWidth() : Modifier.Companion) + if (cornerRadius >= 0) + button.Shape = new RoundedCornerShape(new Dp(cornerRadius)); + if (padding != Thickness.Zero) + button.ContentPadding = new PaddingValues( + start: new Dp((float)padding.Left), + top: new Dp((float)padding.Top), + end: new Dp((float)padding.Right), + bottom: new Dp((float)padding.Bottom)); + // Optional stroke chain — Compose Button has no built-in border slot. + // Wrap the outer Modifier with Modifier.Border when a stroke is set. + var outer = (_fillWidth.Value ? Modifier.FillMaxWidth() : Modifier.Companion); + if (strokeColor.HasValue && strokeThickness > 0f) + outer = outer.Border( + new Dp(strokeThickness), + new ComposeColor(strokeColor.Value), + button.Shape); + outer = outer .ApplyViewProperties(virtualView) .ApplyGestures(virtualView, MauiContext) .ApplySemantics(virtualView); @@ -96,6 +163,13 @@ public override ComposableNode BuildNode(IComposer composer) return button; } + /// + protected override void DisconnectHandler(ComposeView platformView) + { + _loader?.Reset(); + base.DisconnectHandler(platformView); + } + void OnClicked() { // Stock MAUI ButtonHandler raises Pressed → Clicked → Released in @@ -173,4 +247,76 @@ public static void MapHorizontalLayoutAlignment(ButtonHandler handler, IButton b } handler._fillWidth.Value = fill; } + + /// + /// Map to the Compose + /// Text.LetterSpacing slot on the button's inner label. + /// + public static void MapCharacterSpacing(ButtonHandler handler, IButton button) + { + if (button is ITextStyle ts) + handler._letterSpacing.Value = ts.CharacterSpacing != 0 + ? (float)ts.CharacterSpacing + : null; + } + + /// + /// Map (size + bold) to the Compose + /// Text.FontSize and Text.FontWeight slots. + /// Custom font families and italic land in a later phase. + /// + public static void MapFont(ButtonHandler handler, IButton button) + { + if (button is not ITextStyle ts) return; + handler._fontSize.Value = ts.Font.Size > 0 ? (int)ts.Font.Size : null; + handler._bold.Value = (ts.Font.Weight & Microsoft.Maui.FontWeight.Bold) + == Microsoft.Maui.FontWeight.Bold; + } + + /// + /// Map to the Compose + /// Button.Shape slot via . + /// + public static void MapCornerRadius(ButtonHandler handler, IButton button) + { + if (button is IButtonStroke stroke) + handler._cornerRadius.Value = stroke.CornerRadius; + } + + /// + /// Map to the outer + /// Modifier.Border chain. Compose's Material 3 Button + /// has no built-in border slot, so we draw the stroke around the + /// button frame instead. + /// + public static void MapStrokeColor(ButtonHandler handler, IButton button) + { + if (button is IButtonStroke stroke) + handler._strokeColor.Value = ColorMapping.ToPackedLong(stroke.StrokeColor); + } + + /// + /// Map to the outer + /// Modifier.Border chain. See . + /// + public static void MapStrokeThickness(ButtonHandler handler, IButton button) + { + if (button is IButtonStroke stroke) + handler._strokeThickness.Value = (float)stroke.StrokeThickness; + } + + /// + /// Map to the Compose + /// Button.ContentPadding slot. Live-read in . + /// + public static void MapPadding(ButtonHandler handler, IButton button) => + handler._paddingVersion.Value = handler._paddingVersion.Value + 1; + + /// + /// Map through the shared + /// ; the resolved drawable or painter + /// is rendered inline as a leading icon inside the button row. + /// + public static async void MapImageSource(ButtonHandler handler, IButton button) => + await handler.Loader.LoadAsync((button as Microsoft.Maui.IImage)?.Source).ConfigureAwait(false); } From 99a717167b736bd41ec3b33e333a2ace42408173 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 12 Jun 2026 17:59:32 -0500 Subject: [PATCH 4/7] Wire EntryHandler/EditorHandler/SearchBarHandler text-input mapper keys Refactor the three text-input handlers (Entry, Editor, SearchBar) onto Compose's MutableState overload via a TextFieldValueState : MutableState subclass that intercepts writes to enforce ITextInput.MaxLength and mirror text + caret/selection back to the MAUI virtual view. New keys wired: CharacterSpacing, HorizontalTextAlignment, IsSpellCheckEnabled, IsTextPredictionEnabled, MaxLength, PlaceholderColor, ReturnType (Entry/SearchBar), CursorPosition, SelectionLength, ClearButtonVisibility (Entry trailing X), SearchIconColor + CancelButtonColor (SearchBar). Deliberate TODOs (left unmapped to surface in the coverage report): - VerticalTextAlignment on Entry/Editor/SearchBar/Label/Picker: Box facade doesn't yet expose contentAlignment slot, so we can't wrap and align. Coverage: 857 -> 883 / 1224 keys (70.0%% -> 72.1%%). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/maui-coverage.md | 104 ++---- .../Handlers/EditorHandler.cs | 270 ++++++++++---- .../Handlers/EntryHandler.cs | 342 +++++++++++++++--- .../Handlers/SearchBarHandler.cs | 309 +++++++++++++--- 4 files changed, 792 insertions(+), 233 deletions(-) diff --git a/docs/maui-coverage.md b/docs/maui-coverage.md index 99dd5d64..24d3799e 100644 --- a/docs/maui-coverage.md +++ b/docs/maui-coverage.md @@ -1,6 +1,6 @@ # .NET MAUI ⇄ Microsoft.AndroidX.Compose.Maui backend coverage -Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:45 UTC. +Generated by `scripts/maui-coverage.cs` on 2026-06-12 22:58 UTC. Pinned MAUI version: **10.0.20** (from `Directory.Build.targets`). @@ -15,7 +15,7 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, - **Stock MAUI handlers in scope**: 43 - **Handlers we override**: 24 (**55.8%**) -- **Property-mapper keys covered**: 857 / 1224 (**70.0%**) +- **Property-mapper keys covered**: 883 / 1224 (**72.1%**) ### Per-category coverage @@ -23,7 +23,7 @@ collected transitively across base mappers (`ViewHandler.ViewMapper`, | --- | --- | --- | | **Pages / Navigation** | 1/4 (25%) | 33/130 (25%) | | **Containers** | 5/5 (100%) | 166/174 (95%) | -| **Leaves** | 18/18 (100%) | 658/695 (95%) | +| **Leaves** | 18/18 (100%) | 684/695 (98%) | | **Menus / Toolbar** | 0/7 (0%) | 0/1 (0%) | | **Shapes** | 0/1 (0%) | 0/41 (0%) | | **App / Window** | 0/2 (0%) | 0/8 (0%) | @@ -48,8 +48,8 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem | ✅ | `ButtonHandler` | Leaves | `Button` | `ButtonHandler` | 40 / 40 (100%) | | ✅ | `CheckBoxHandler` | Leaves | `CheckBox` | `CheckBoxHandler` | 33 / 33 (100%) | | ✅ | `DatePickerHandler` | Leaves | `DatePicker` | `DatePickerHandler` | 39 / 39 (100%) | -| 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 38 / 46 (83%) | -| 🟡 | `EntryHandler` | Leaves | `Entry` | `EntryHandler` | 38 / 49 (78%) | +| 🟡 | `EditorHandler` | Leaves | `Editor` | `EditorHandler` | 45 / 46 (98%) | +| 🟡 | `EntryHandler` | Leaves | `Entry` | `EntryHandler` | 48 / 49 (98%) | | 🟡 | `ImageButtonHandler` | Leaves | `ImageButton` | `ImageButtonHandler` | 37 / 38 (97%) | | 🟡 | `ImageHandler` | Leaves | `Image` | `ImageHandler` | 33 / 34 (97%) | | ✅ | `IndicatorViewHandler` | Leaves | `IndicatorView` | `IndicatorViewHandler` | 39 / 39 (100%) | @@ -57,7 +57,7 @@ Sorted by category. ✅ = fully covered (100%), 🟡 = partial, ❌ = not implem | 🟡 | `PickerHandler` | Leaves | `Picker` | `PickerHandler` | 40 / 41 (98%) | | ✅ | `ProgressBarHandler` | Leaves | `ProgressBar` | `ProgressBarHandler` | 33 / 33 (100%) | | 🟡 | `RadioButtonHandler` | Leaves | `RadioButton` | `RadioButtonHandler` | 36 / 39 (92%) | -| 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 37 / 47 (79%) | +| 🟡 | `SearchBarHandler` | Leaves | `SearchBar` | `SearchBarHandler` | 46 / 47 (98%) | | 🟡 | `SliderHandler` | Leaves | `Slider` | `SliderHandler` | 37 / 38 (97%) | | ✅ | `StepperHandler` | Leaves | `Stepper` | `StepperHandler` | 35 / 35 (100%) | | ✅ | `SwitchHandler` | Leaves | `Switch` | `SwitchHandler` | 34 / 34 (100%) | @@ -112,9 +112,6 @@ Most often this is a non-trivial property we haven't wired up yet (`CharacterSpacing`, `Font`, `Padding` on `Button`; `CornerRadius`, dashed stroke patterns on `Border`). -- **`EntryHandler`** (78%) — missing: `CharacterSpacing`, `ClearButtonVisibility`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SelectionLength`, `VerticalTextAlignment` -- **`SearchBarHandler`** (79%) — missing: `CancelButtonColor`, `CharacterSpacing`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `MaxLength`, `PlaceholderColor`, `ReturnType`, `SearchIconColor`, `VerticalTextAlignment` -- **`EditorHandler`** (83%) — missing: `CharacterSpacing`, `CursorPosition`, `HorizontalTextAlignment`, `IsSpellCheckEnabled`, `IsTextPredictionEnabled`, `PlaceholderColor`, `SelectionLength`, `VerticalTextAlignment` - **`BorderHandler`** (88%) — missing: `StrokeDashOffset`, `StrokeDashPattern`, `StrokeLineCap`, `StrokeLineJoin`, `StrokeMiterLimit` - **`RadioButtonHandler`** (92%) — missing: `CornerRadius`, `StrokeColor`, `StrokeThickness` - **`ScrollViewHandler`** (94%) — missing: `HorizontalScrollBarVisibility`, `VerticalScrollBarVisibility` @@ -124,6 +121,9 @@ dashed stroke patterns on `Border`). - **`SliderHandler`** (97%) — missing: `ThumbImageSource` - **`LabelHandler`** (98%) — missing: `VerticalTextAlignment` - **`PickerHandler`** (98%) — missing: `VerticalTextAlignment` +- **`EditorHandler`** (98%) — missing: `VerticalTextAlignment` +- **`SearchBarHandler`** (98%) — missing: `VerticalTextAlignment` +- **`EntryHandler`** (98%) — missing: `VerticalTextAlignment` ## Per-handler property detail @@ -742,17 +742,10 @@ Extra keys we map (no stock counterpart): ### 🟡 `EditorHandler` — `Editor` -Backed by `EditorHandler`. **38 / 46 keys (83%)**. +Backed by `EditorHandler`. **45 / 46 keys (98%)**. Missing keys: -- [ ] `CharacterSpacing` -- [ ] `CursorPosition` -- [ ] `HorizontalTextAlignment` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` -- [ ] `PlaceholderColor` -- [ ] `SelectionLength` - [ ] `VerticalTextAlignment` Extra keys we map (no stock counterpart): @@ -766,19 +759,19 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` -- [ ] `CursorPosition` +- [x] `CursorPosition` - [x] `FlowDirection` - [x] `Font` - [x] `Height` -- [ ] `HorizontalTextAlignment` +- [x] `HorizontalTextAlignment` - [x] `InputTransparent` - [x] `IsEnabled` - [x] `IsReadOnly` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` +- [x] `IsSpellCheckEnabled` +- [x] `IsTextPredictionEnabled` - [x] `Keyboard` - [x] `MaxLength` - [x] `MaximumHeight` @@ -787,7 +780,7 @@ Extra keys we map (no stock counterpart): - [x] `MinimumWidth` - [x] `Opacity` - [x] `Placeholder` -- [ ] `PlaceholderColor` +- [x] `PlaceholderColor` - [x] `Rotation` - [x] `RotationX` - [x] `RotationY` @@ -795,7 +788,7 @@ Extra keys we map (no stock counterpart): - [x] `Scale` - [x] `ScaleX` - [x] `ScaleY` -- [ ] `SelectionLength` +- [x] `SelectionLength` - [x] `Semantics` - [x] `Shadow` - [x] `Text` @@ -812,20 +805,10 @@ Extra keys we map (no stock counterpart): ### 🟡 `EntryHandler` — `Entry` -Backed by `EntryHandler`. **38 / 49 keys (78%)**. +Backed by `EntryHandler`. **48 / 49 keys (98%)**. Missing keys: -- [ ] `CharacterSpacing` -- [ ] `ClearButtonVisibility` -- [ ] `CursorPosition` -- [ ] `HorizontalTextAlignment` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` -- [ ] `MaxLength` -- [ ] `PlaceholderColor` -- [ ] `ReturnType` -- [ ] `SelectionLength` - [ ] `VerticalTextAlignment` Extra keys we map (no stock counterpart): @@ -839,31 +822,31 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CharacterSpacing` -- [ ] `ClearButtonVisibility` +- [x] `CharacterSpacing` +- [x] `ClearButtonVisibility` - [x] `Clip` - [x] `ContainerView` -- [ ] `CursorPosition` +- [x] `CursorPosition` - [x] `FlowDirection` - [x] `Font` - [x] `Height` -- [ ] `HorizontalTextAlignment` +- [x] `HorizontalTextAlignment` - [x] `InputTransparent` - [x] `IsEnabled` - [x] `IsPassword` - [x] `IsReadOnly` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` +- [x] `IsSpellCheckEnabled` +- [x] `IsTextPredictionEnabled` - [x] `Keyboard` -- [ ] `MaxLength` +- [x] `MaxLength` - [x] `MaximumHeight` - [x] `MaximumWidth` - [x] `MinimumHeight` - [x] `MinimumWidth` - [x] `Opacity` - [x] `Placeholder` -- [ ] `PlaceholderColor` -- [ ] `ReturnType` +- [x] `PlaceholderColor` +- [x] `ReturnType` - [x] `Rotation` - [x] `RotationX` - [x] `RotationY` @@ -871,7 +854,7 @@ Extra keys we map (no stock counterpart): - [x] `Scale` - [x] `ScaleX` - [x] `ScaleY` -- [ ] `SelectionLength` +- [x] `SelectionLength` - [x] `Semantics` - [x] `Shadow` - [x] `Text` @@ -1246,24 +1229,17 @@ Missing keys: ### 🟡 `SearchBarHandler` — `SearchBar` -Backed by `SearchBarHandler`. **37 / 47 keys (79%)**. +Backed by `SearchBarHandler`. **46 / 47 keys (98%)**. Missing keys: -- [ ] `CancelButtonColor` -- [ ] `CharacterSpacing` -- [ ] `HorizontalTextAlignment` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` -- [ ] `MaxLength` -- [ ] `PlaceholderColor` -- [ ] `ReturnType` -- [ ] `SearchIconColor` - [ ] `VerticalTextAlignment` Extra keys we map (no stock counterpart): +- `CursorPosition` - `HorizontalLayoutAlignment` +- `SelectionLength`
All stock keys @@ -1272,29 +1248,29 @@ Extra keys we map (no stock counterpart): - [x] `AutomationId` - [x] `Background` - [x] `Border` -- [ ] `CancelButtonColor` -- [ ] `CharacterSpacing` +- [x] `CancelButtonColor` +- [x] `CharacterSpacing` - [x] `Clip` - [x] `ContainerView` - [x] `FlowDirection` - [x] `Font` - [x] `Height` -- [ ] `HorizontalTextAlignment` +- [x] `HorizontalTextAlignment` - [x] `InputTransparent` - [x] `IsEnabled` - [x] `IsReadOnly` -- [ ] `IsSpellCheckEnabled` -- [ ] `IsTextPredictionEnabled` +- [x] `IsSpellCheckEnabled` +- [x] `IsTextPredictionEnabled` - [x] `Keyboard` -- [ ] `MaxLength` +- [x] `MaxLength` - [x] `MaximumHeight` - [x] `MaximumWidth` - [x] `MinimumHeight` - [x] `MinimumWidth` - [x] `Opacity` - [x] `Placeholder` -- [ ] `PlaceholderColor` -- [ ] `ReturnType` +- [x] `PlaceholderColor` +- [x] `ReturnType` - [x] `Rotation` - [x] `RotationX` - [x] `RotationY` @@ -1302,7 +1278,7 @@ Extra keys we map (no stock counterpart): - [x] `Scale` - [x] `ScaleX` - [x] `ScaleY` -- [ ] `SearchIconColor` +- [x] `SearchIconColor` - [x] `Semantics` - [x] `Shadow` - [x] `Text` diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs index 48f86d97..7e8b01bc 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs @@ -1,5 +1,7 @@ using AndroidX.Compose; using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Text; +using AndroidX.Compose.UI.Text.Input; using Microsoft.AndroidX.Compose.Maui.Platform; using Microsoft.Maui.Handlers; using ComposeColor = AndroidX.Compose.Color; @@ -7,6 +9,7 @@ using ComposeKeyboardType = AndroidX.Compose.KeyboardType; using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField; using ComposeText = AndroidX.Compose.Text; +using ComposeTextAlign = AndroidX.Compose.TextAlign; using ComposeTextStyle = AndroidX.Compose.TextStyle; namespace Microsoft.AndroidX.Compose.Maui.Handlers; @@ -29,12 +32,12 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers; /// so the field starts tall enough to look like a /// TextArea. /// -/// is enforced inside -/// by truncating the new text -/// — Compose's OutlinedTextField doesn't expose a -/// maxLength slot directly. -/// is a no-op: Compose's multi-line OutlinedTextField -/// already grows with content when no maxLines is set. +/// Like , value/cursor/selection state +/// is bound through Compose's +/// OutlinedTextField(MutableState<TextFieldValue>) +/// overload via a custom subclass that +/// enforces and mirrors text + +/// caret back to MAUI's . /// public partial class EditorHandler : ComposeElementHandler { @@ -46,36 +49,57 @@ public partial class EditorHandler : ComposeElementHandler public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IText.Text)] = MapText, - [nameof(ITextStyle.TextColor)] = MapTextColor, - [nameof(ITextStyle.Font)] = MapFont, - [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, - [nameof(ITextInput.Keyboard)] = MapKeyboard, - [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, - [nameof(ITextInput.MaxLength)] = MapMaxLength, - [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + [nameof(IText.Text)] = MapText, + [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + [nameof(ITextStyle.Font)] = MapFont, + [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, + [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor, + [nameof(ITextInput.Keyboard)] = MapKeyboard, + [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, + [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect, + [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect, + [nameof(ITextInput.MaxLength)] = MapMaxLength, + [nameof(ITextInput.CursorPosition)] = MapCursorPosition, + [nameof(ITextInput.SelectionLength)] = MapSelectionLength, + [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + // TODO: VerticalTextAlignment — Compose's Box facade doesn't + // expose the `contentAlignment` slot (always passes null), so + // we can't wrap the OutlinedTextField and align it. Leaving + // unmapped instead of wiring a no-op mapper. + [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, }; /// Command mapper (inherits view-level commands; no extras). public static CommandMapper CommandMapper = new(ViewCommandMapper); - readonly MutableState _text = new(string.Empty); - readonly MutableState _color = new((long?)null); - readonly MutableState _fontSize = new((int?)null); - readonly MutableState _bold = new(false); - readonly MutableState _placeholder = new(string.Empty); - readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); - readonly MutableState _readOnly = new(false); - readonly MutableState _maxLength = new(-1); - readonly MutableState _fillWidth = new(false); + readonly TextFieldValueState _tfv; + readonly MutableState _color = new((long?)null); + readonly MutableState _placeholderColor = new((long?)null); + readonly MutableState _fontSize = new((int?)null); + readonly MutableState _bold = new(false); + readonly MutableState _placeholder = new(string.Empty); + readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); + readonly MutableState _readOnly = new(false); + readonly MutableState _autoCorrect = new(true); + readonly MutableState _maxLength = new(-1); + readonly MutableState _letterSpacing = new((float?)null); + readonly MutableState _hTextAlign = new((int)TextAlignment.Start); + readonly MutableState _fillWidth = new(false); /// Construct a handler with the default mappers. - public EditorHandler() : base(Mapper, CommandMapper) { } + public EditorHandler() : base(Mapper, CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// Construct a handler with custom mappers. public EditorHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null) - : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { } + : base(mapper ?? Mapper, commandMapper ?? CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// public override ComposableNode BuildNode(IComposer composer) @@ -83,37 +107,51 @@ public override ComposableNode BuildNode(IComposer composer) var virtualView = VirtualView ?? throw new InvalidOperationException("VirtualView not set on EditorHandler."); - var packed = _color.Value; - var size = _fontSize.Value; - var bold = _bold.Value; - var placeholder = _placeholder.Value; - var keyboardType = _keyboardType.Value; - var readOnly = _readOnly.Value; - var fill = _fillWidth.Value; + var packed = _color.Value; + var placeholderInk = _placeholderColor.Value; + var size = _fontSize.Value; + var bold = _bold.Value; + var placeholder = _placeholder.Value; + var keyboardType = _keyboardType.Value; + var readOnly = _readOnly.Value; + var autoCorrect = _autoCorrect.Value; + var letterSpacing = _letterSpacing.Value; + var hTextAlign = (TextAlignment)_hTextAlign.Value; + var fill = _fillWidth.Value; - var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged) + var field = new ComposeOutlinedTextField(_tfv) { ReadOnly = readOnly, // Multi-line — the whole point of Editor. SingleLine = false, }; if (!string.IsNullOrEmpty(placeholder)) - field.Placeholder = new ComposeText(placeholder); - if (packed.HasValue || size.HasValue || bold) + field.Placeholder = new ComposeText(placeholder) + { + Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null, + }; + if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue + || hTextAlign != TextAlignment.Start) field.TextStyle = new ComposeTextStyle { - Color = packed.HasValue ? new ComposeColor(packed.Value) : null, - FontSize = size.HasValue ? new Sp(size.Value) : null, - FontWeight = bold ? ComposeFontWeight.Bold : null, + Color = packed.HasValue ? new ComposeColor(packed.Value) : null, + FontSize = size.HasValue ? new Sp(size.Value) : null, + FontWeight = bold ? ComposeFontWeight.Bold : null, + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + TextAlign = hTextAlign switch + { + TextAlignment.Center => ComposeTextAlign.Center, + TextAlignment.End => ComposeTextAlign.End, + _ => null, + }, }; - if (keyboardType != ComposeKeyboardType.Text) - { - var d = KeyboardOptionsCompanion.Default; - field.KeyboardOptions = d.Copy( - d.Capitalization, d.AutoCorrectEnabled, - keyboardType, d.ImeAction, - d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales); - } + // KeyboardOptions: always set so autocorrect + keyboardType + // overrides take effect — same pattern as EntryHandler. + var d = KeyboardOptionsCompanion.Default; + field.KeyboardOptions = d.Copy( + d.Capitalization, (Java.Lang.Boolean)autoCorrect, + keyboardType, d.ImeAction, + d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales); // Tall default — feels like an editor, not a one-line entry. // Stack on top of any caller-supplied modifier (Layout chains). @@ -125,30 +163,28 @@ public override ComposableNode BuildNode(IComposer composer) return field; } - void OnValueChanged(string newValue) + /// Map to the Compose value slot. + public static void MapText(EditorHandler handler, IEditor editor) { - // MAUI Editor.MaxLength: truncate before push-back so the user - // can't sneak past the cap. Compose's OutlinedTextField has no - // built-in maxLength slot. - var max = _maxLength.Value; - if (max >= 0 && newValue.Length > max) - newValue = newValue.Substring(0, max); - - // Update Compose state synchronously so the rendered value stays - // pinned to what the user just typed (mirrors EntryHandler). - _text.Value = newValue; - if (VirtualView is { } editor) - editor.Text = newValue; + var newText = editor.Text ?? string.Empty; + var current = handler._tfv.Value; + if (current?.Text == newText) return; + var cursor = Math.Min(editor.CursorPosition, newText.Length); + if (cursor < 0) cursor = newText.Length; + handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue( + newText, TextRangeKt.TextRange(cursor), composition: null)); } - /// Map to the Compose value slot. - public static void MapText(EditorHandler handler, IEditor editor) => - handler._text.Value = editor.Text ?? string.Empty; - /// Map to the Compose TextStyle.Color slot. public static void MapTextColor(EditorHandler handler, IEditor editor) => handler._color.Value = ColorMapping.ToPackedLong(editor.TextColor); + /// Map to Compose letterSpacing. + public static void MapCharacterSpacing(EditorHandler handler, IEditor editor) => + handler._letterSpacing.Value = editor.CharacterSpacing != 0 + ? (float)editor.CharacterSpacing + : null; + /// Map (size + bold) to Compose TextStyle slots. public static void MapFont(EditorHandler handler, IEditor editor) { @@ -162,6 +198,10 @@ public static void MapFont(EditorHandler handler, IEditor editor) public static void MapPlaceholder(EditorHandler handler, IEditor editor) => handler._placeholder.Value = editor.Placeholder ?? string.Empty; + /// Map to the placeholder Text's color. + public static void MapPlaceholderColor(EditorHandler handler, IEditor editor) => + handler._placeholderColor.Value = ColorMapping.ToPackedLong(editor.PlaceholderColor); + /// Map to a Compose KeyboardType int. public static void MapKeyboard(EditorHandler handler, IEditor editor) => handler._keyboardType.Value = KeyboardMapping.Resolve(editor.Keyboard, nameof(EditorHandler)); @@ -170,9 +210,53 @@ public static void MapKeyboard(EditorHandler handler, IEditor editor) => public static void MapIsReadOnly(EditorHandler handler, IEditor editor) => handler._readOnly.Value = editor.IsReadOnly; + /// + /// Combined map for + + /// . Compose has a + /// single autoCorrectEnabled slot driving both; we AND the + /// MAUI flags (autocorrect is only on if both checks pass). + /// + public static void MapAutoCorrect(EditorHandler handler, IEditor editor) => + handler._autoCorrect.Value = editor.IsSpellCheckEnabled && editor.IsTextPredictionEnabled; + /// Map to the truncation cap (negative = unlimited). - public static void MapMaxLength(EditorHandler handler, IEditor editor) => + public static void MapMaxLength(EditorHandler handler, IEditor editor) + { handler._maxLength.Value = editor.MaxLength; + // Re-truncate the current buffer if it's now over the cap. + if (editor.MaxLength >= 0 + && handler._tfv.Value is { } current + && (current.Text?.Length ?? 0) > editor.MaxLength) + { + handler._tfv.Value = current; + } + } + + /// Map to the TFV selection start. + public static void MapCursorPosition(EditorHandler handler, IEditor editor) => + ApplySelection(handler, editor); + + /// Map to the TFV selection end. + public static void MapSelectionLength(EditorHandler handler, IEditor editor) => + ApplySelection(handler, editor); + + static void ApplySelection(EditorHandler handler, IEditor editor) + { + var current = handler._tfv.Value; + if (current is null) return; + var text = current.Text ?? string.Empty; + var start = Math.Clamp(editor.CursorPosition, 0, text.Length); + var length = Math.Max(0, Math.Min(editor.SelectionLength, text.Length - start)); + var end = start + length; + var existing = current.Selection; + if ((int)existing == start && (int)(existing >> 32) == end) + return; + handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null)); + } + + /// Map to Compose textAlign. + public static void MapHorizontalTextAlignment(EditorHandler handler, IEditor editor) => + handler._hTextAlign.Value = (int)editor.HorizontalTextAlignment; /// /// Map to @@ -182,4 +266,62 @@ public static void MapMaxLength(EditorHandler handler, IEditor editor) => public static void MapHorizontalLayoutAlignment(EditorHandler handler, IEditor editor) => handler._fillWidth.Value = editor.HorizontalLayoutAlignment == Microsoft.Maui.Primitives.LayoutAlignment.Fill; + + /// + /// of + /// that intercepts every write so we can enforce + /// (Compose has no built-in + /// slot) and mirror text + caret back to MAUI's + /// . Mirrors the + /// pattern. + /// + sealed class TextFieldValueState : MutableState + { + readonly EditorHandler _owner; + bool _suppressMirror; + + public TextFieldValueState(EditorHandler owner) + : base(ComposeExtensions.NewTextFieldValue()) + { + _owner = owner; + } + + public void SetWithoutMirror(TextFieldValue value) + { + _suppressMirror = true; + try { Value = value; } + finally { _suppressMirror = false; } + } + + public override TextFieldValue Value + { + get => base.Value; + set + { + var max = _owner._maxLength.Value; + if (max >= 0 && value?.Text is { } text && text.Length > max) + { + var trunc = text[..max]; + var sel = value.Selection; + var start = Math.Min((int)sel, max); + var end = Math.Min((int)(sel >> 32), max); + value = ComposeExtensions.NewTextFieldValue( + trunc, TextRangeKt.TextRange(start, end), composition: null); + } + base.Value = value!; + if (!_suppressMirror && _owner.VirtualView is { } editor && value is not null) + { + var newText = value.Text ?? string.Empty; + if (editor.Text != newText) + editor.Text = newText; + var caret = (int)value.Selection; + var selLen = (int)(value.Selection >> 32) - caret; + if (editor.CursorPosition != caret) + editor.CursorPosition = caret; + if (editor.SelectionLength != selLen) + editor.SelectionLength = selLen; + } + } + } + } } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs index 9a3d018a..45644484 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs @@ -1,13 +1,16 @@ using AndroidX.Compose; using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Text; using AndroidX.Compose.UI.Text.Input; using Microsoft.AndroidX.Compose.Maui.Platform; using Microsoft.Maui.Handlers; using ComposeColor = AndroidX.Compose.Color; using ComposeFontWeight = AndroidX.Compose.FontWeight; +using ComposeImeAction = AndroidX.Compose.ImeAction; using ComposeKeyboardType = AndroidX.Compose.KeyboardType; using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField; using ComposeText = AndroidX.Compose.Text; +using ComposeTextAlign = AndroidX.Compose.TextAlign; using ComposeTextStyle = AndroidX.Compose.TextStyle; namespace Microsoft.AndroidX.Compose.Maui.Handlers; @@ -20,13 +23,20 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers; /// . /// /// -/// Folds into the page's single composition via +/// Folds into the page's single composition via /// / /// . Uses OutlinedTextField /// rather than the filled TextField variant because it /// matches MAUI's stock Entry chrome more closely (clear bordered /// outline, no shaded background fill, label that floats above the -/// border when the field gains focus). +/// border when the field gains focus). +/// +/// The value/cursor/selection state is bound through Compose's +/// OutlinedTextField(MutableState<TextFieldValue>) overload +/// — a custom subclass intercepts every +/// write so we can enforce +/// (Compose has no built-in slot) and mirror text + caret back to +/// MAUI's . /// public partial class EntryHandler : ComposeElementHandler { @@ -37,36 +47,64 @@ public partial class EntryHandler : ComposeElementHandler public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IText.Text)] = MapText, - [nameof(ITextStyle.TextColor)] = MapTextColor, - [nameof(ITextStyle.Font)] = MapFont, - [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, - [nameof(IEntry.IsPassword)] = MapIsPassword, - [nameof(ITextInput.Keyboard)] = MapKeyboard, - [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, - [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + [nameof(IText.Text)] = MapText, + [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + [nameof(ITextStyle.Font)] = MapFont, + [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, + [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor, + [nameof(IEntry.IsPassword)] = MapIsPassword, + [nameof(IEntry.ReturnType)] = MapReturnType, + [nameof(IEntry.ClearButtonVisibility)] = MapClearButtonVisibility, + [nameof(ITextInput.Keyboard)] = MapKeyboard, + [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, + [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect, + [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect, + [nameof(ITextInput.MaxLength)] = MapMaxLength, + [nameof(ITextInput.CursorPosition)] = MapCursorPosition, + [nameof(ITextInput.SelectionLength)] = MapSelectionLength, + [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + // TODO: VerticalTextAlignment — single-line OutlinedTextField has + // no meaningful vertical alignment slot; Compose centres the + // baseline by default. Stock MAUI Entry only honours this + // alignment when the Entry's Height exceeds line height, which + // is uncommon. Skip until a multi-line Entry use-case appears. }; /// Command mapper (inherits view-level commands; no extras). public static CommandMapper CommandMapper = new(ViewCommandMapper); - readonly MutableState _text = new(string.Empty); - readonly MutableState _color = new((long?)null); - readonly MutableState _fontSize = new((int?)null); - readonly MutableState _bold = new(false); - readonly MutableState _placeholder = new(string.Empty); - readonly MutableState _isPassword = new(false); - readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); - readonly MutableState _readOnly = new(false); - readonly MutableState _fillWidth = new(false); + readonly TextFieldValueState _tfv; + readonly MutableState _color = new((long?)null); + readonly MutableState _placeholderColor = new((long?)null); + readonly MutableState _fontSize = new((int?)null); + readonly MutableState _bold = new(false); + readonly MutableState _placeholder = new(string.Empty); + readonly MutableState _isPassword = new(false); + readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); + readonly MutableState _readOnly = new(false); + readonly MutableState _autoCorrect = new(true); + readonly MutableState _imeAction = new(ComposeImeAction.Default); + readonly MutableState _letterSpacing = new((float?)null); + readonly MutableState _hTextAlign = new((int)TextAlignment.Start); + readonly MutableState _clearButtonMode = new((int)ClearButtonVisibility.Never); + readonly MutableState _maxLength = new(-1); + readonly MutableState _fillWidth = new(false); /// Construct a handler with the default mappers. - public EntryHandler() : base(Mapper, CommandMapper) { } + public EntryHandler() : base(Mapper, CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// Construct a handler with custom mappers. public EntryHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null) - : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { } + : base(mapper ?? Mapper, commandMapper ?? CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// public override ComposableNode BuildNode(IComposer composer) @@ -76,43 +114,85 @@ public override ComposableNode BuildNode(IComposer composer) SubscribeToViewProperties(); - var packed = _color.Value; - var size = _fontSize.Value; - var bold = _bold.Value; - var placeholder = _placeholder.Value; - var isPassword = _isPassword.Value; - var keyboardType = _keyboardType.Value; - var readOnly = _readOnly.Value; - var fill = _fillWidth.Value; + var packed = _color.Value; + var placeholderInk = _placeholderColor.Value; + var size = _fontSize.Value; + var bold = _bold.Value; + var placeholder = _placeholder.Value; + var isPassword = _isPassword.Value; + var keyboardType = _keyboardType.Value; + var readOnly = _readOnly.Value; + var autoCorrect = _autoCorrect.Value; + var imeAction = _imeAction.Value; + var letterSpacing = _letterSpacing.Value; + var hTextAlign = (TextAlignment)_hTextAlign.Value; + var clearButtonMode = (ClearButtonVisibility)_clearButtonMode.Value; + var fill = _fillWidth.Value; - var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged) + var field = new ComposeOutlinedTextField(_tfv) { ReadOnly = readOnly, SingleLine = true, }; if (!string.IsNullOrEmpty(placeholder)) - field.Placeholder = new ComposeText(placeholder); - if (packed.HasValue || size.HasValue || bold) + field.Placeholder = new ComposeText(placeholder) + { + Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null, + }; + if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue + || hTextAlign != TextAlignment.Start) field.TextStyle = new ComposeTextStyle { - Color = packed.HasValue ? new ComposeColor(packed.Value) : null, - FontSize = size.HasValue ? new Sp(size.Value) : null, - FontWeight = bold ? ComposeFontWeight.Bold : null, + Color = packed.HasValue ? new ComposeColor(packed.Value) : null, + FontSize = size.HasValue ? new Sp(size.Value) : null, + FontWeight = bold ? ComposeFontWeight.Bold : null, + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + TextAlign = hTextAlign switch + { + TextAlignment.Center => ComposeTextAlign.Center, + TextAlignment.End => ComposeTextAlign.End, + _ => null, + }, }; if (isPassword) field.VisualTransformation = new PasswordVisualTransformation('•'); - // Always set KeyboardOptions when the resolved type isn't the - // default `Text`, so the OS surfaces the right IME (digits, - // email, etc.). Password buffers + numeric flag both lower - // through `keyboardType`. + // Always set KeyboardOptions so OS surfaces the right IME action + + // honours autocorrect / keyboard-type overrides. Password buffers + + // numeric flag both lower through `keyboardType`. var resolvedType = isPassword ? ComposeKeyboardType.Password : keyboardType; - if (resolvedType != ComposeKeyboardType.Text) + var d = KeyboardOptionsCompanion.Default; + field.KeyboardOptions = d.Copy( + d.Capitalization, (Java.Lang.Boolean)autoCorrect, + resolvedType, imeAction, + d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales); + + // Wire the IME action callback so MAUI Entry.Completed (and + // SearchButtonPressed-style return-key handling) fires when the + // user taps the IME's action key. + field.KeyboardActions = KeyboardActionsHelper.Create( + onDone: OnCompleted, + onGo: OnCompleted, + onNext: OnCompleted, + onSearch: OnCompleted, + onSend: OnCompleted, + onPrevious: OnCompleted); + + // Clear-X trailing icon when MAUI's ClearButtonVisibility == + // WhileEditing and the text is non-empty. We always render it + // when WhileEditing because Compose's OutlinedTextField doesn't + // expose a "focused" flag without subscribing to FocusInteraction + // state — close enough for parity with stock MAUI on Android. + if (clearButtonMode == ClearButtonVisibility.WhileEditing + && !string.IsNullOrEmpty(_tfv.Value?.Text)) { - var d = KeyboardOptionsCompanion.Default; - field.KeyboardOptions = d.Copy( - d.Capitalization, d.AutoCorrectEnabled, - resolvedType, d.ImeAction, - d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales); + field.TrailingIcon = new IconButton(onClick: () => + { + if (VirtualView is { } entry) + entry.Text = string.Empty; + }) + { + new ComposeText("\u2715"), // ✕ + }; } // Single chained PrependModifier — combines layout-fill with @@ -125,28 +205,36 @@ public override ComposableNode BuildNode(IComposer composer) return field; } - void OnValueChanged(string newValue) + void OnCompleted() { - // Update Compose state synchronously so the rendered value stays - // pinned to what the user just typed (Compose snaps `value` - // back on the next recompose; lagging here drops keystrokes). - // Updating VirtualView.Text after triggers MAUI's standard - // property pipeline (data binding, behaviors, validation) which - // re-enters MapText with the same string — that's a no-op on - // MutableState, so no feedback loop. - _text.Value = newValue; - if (VirtualView is { } entry) - entry.Text = newValue; + if (VirtualView is Microsoft.Maui.Controls.Entry e) + e.SendCompleted(); } /// Map to the Compose value slot. - public static void MapText(EntryHandler handler, IEntry entry) => - handler._text.Value = entry.Text ?? string.Empty; + public static void MapText(EntryHandler handler, IEntry entry) + { + var newText = entry.Text ?? string.Empty; + var current = handler._tfv.Value; + if (current?.Text == newText) return; + // Text changed externally — snap cursor to end so the IME + // doesn't try to re-render at a stale offset. + var cursor = Math.Min(entry.CursorPosition, newText.Length); + if (cursor < 0) cursor = newText.Length; + handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue( + newText, TextRangeKt.TextRange(cursor), composition: null)); + } /// Map to the Compose TextStyle.Color slot. public static void MapTextColor(EntryHandler handler, IEntry entry) => handler._color.Value = ColorMapping.ToPackedLong(entry.TextColor); + /// Map to Compose letterSpacing. + public static void MapCharacterSpacing(EntryHandler handler, IEntry entry) => + handler._letterSpacing.Value = entry.CharacterSpacing != 0 + ? (float)entry.CharacterSpacing + : null; + /// Map (size + bold) to Compose TextStyle slots. public static void MapFont(EntryHandler handler, IEntry entry) { @@ -160,10 +248,30 @@ public static void MapFont(EntryHandler handler, IEntry entry) public static void MapPlaceholder(EntryHandler handler, IEntry entry) => handler._placeholder.Value = entry.Placeholder ?? string.Empty; + /// Map to the placeholder Text's color. + public static void MapPlaceholderColor(EntryHandler handler, IEntry entry) => + handler._placeholderColor.Value = ColorMapping.ToPackedLong(entry.PlaceholderColor); + /// Map to the visualTransformation + keyboardType slots. public static void MapIsPassword(EntryHandler handler, IEntry entry) => handler._isPassword.Value = entry.IsPassword; + /// Map to KeyboardOptions.imeAction. + public static void MapReturnType(EntryHandler handler, IEntry entry) => + handler._imeAction.Value = entry.ReturnType switch + { + ReturnType.Done => ComposeImeAction.Done, + ReturnType.Go => ComposeImeAction.Go, + ReturnType.Next => ComposeImeAction.Next, + ReturnType.Search => ComposeImeAction.Search, + ReturnType.Send => ComposeImeAction.Send, + _ => ComposeImeAction.Default, + }; + + /// Map to the trailing X icon toggle. + public static void MapClearButtonVisibility(EntryHandler handler, IEntry entry) => + handler._clearButtonMode.Value = (int)entry.ClearButtonVisibility; + /// Map to a Compose KeyboardType int. public static void MapKeyboard(EntryHandler handler, IEntry entry) => handler._keyboardType.Value = KeyboardMapping.Resolve(entry.Keyboard, nameof(EntryHandler)); @@ -172,6 +280,56 @@ public static void MapKeyboard(EntryHandler handler, IEntry entry) => public static void MapIsReadOnly(EntryHandler handler, IEntry entry) => handler._readOnly.Value = entry.IsReadOnly; + /// + /// Combined map for + + /// . Compose has a single + /// autoCorrectEnabled slot driving both; we AND the MAUI flags + /// (autocorrect is only on if both checks pass). + /// + public static void MapAutoCorrect(EntryHandler handler, IEntry entry) => + handler._autoCorrect.Value = entry.IsSpellCheckEnabled && entry.IsTextPredictionEnabled; + + /// Map to the truncation cap (negative = unlimited). + public static void MapMaxLength(EntryHandler handler, IEntry entry) + { + handler._maxLength.Value = entry.MaxLength; + // Re-truncate the current buffer if it's now over the cap. + if (entry.MaxLength >= 0 + && handler._tfv.Value is { } current + && (current.Text?.Length ?? 0) > entry.MaxLength) + { + handler._tfv.Value = current; // override re-applies truncation + } + } + + /// Map to the TFV selection start. + public static void MapCursorPosition(EntryHandler handler, IEntry entry) => + ApplySelection(handler, entry); + + /// Map to the TFV selection end. + public static void MapSelectionLength(EntryHandler handler, IEntry entry) => + ApplySelection(handler, entry); + + static void ApplySelection(EntryHandler handler, IEntry entry) + { + var current = handler._tfv.Value; + if (current is null) return; + var text = current.Text ?? string.Empty; + var start = Math.Clamp(entry.CursorPosition, 0, text.Length); + var length = Math.Max(0, Math.Min(entry.SelectionLength, text.Length - start)); + var end = start + length; + // Skip writes that wouldn't actually change Compose's selection — + // re-entrance from OnSelectionChanged hits this constantly. + var existing = current.Selection; + if ((int)existing == start && (int)(existing >> 32) == end) + return; + handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null)); + } + + /// Map to Compose textAlign. + public static void MapHorizontalTextAlignment(EntryHandler handler, IEntry entry) => + handler._hTextAlign.Value = (int)entry.HorizontalTextAlignment; + /// /// Map to /// Modifier.fillMaxWidth() when the entry asks to fill its @@ -182,4 +340,72 @@ public static void MapIsReadOnly(EntryHandler handler, IEntry entry) => public static void MapHorizontalLayoutAlignment(EntryHandler handler, IEntry entry) => handler._fillWidth.Value = entry.HorizontalLayoutAlignment == Microsoft.Maui.Primitives.LayoutAlignment.Fill; + + /// + /// of + /// that intercepts every write so we can enforce + /// (Compose has no built-in + /// slot) and mirror the resulting text + caret position back to + /// MAUI's . Compose's + /// OutlinedTextField(MutableState<TextFieldValue>) + /// ctor writes state.Value = newTfv from its + /// onValueChange lambda, so overriding the + /// setter intercepts user edits as well as + /// programmatic writes. + /// + sealed class TextFieldValueState : MutableState + { + readonly EntryHandler _owner; + bool _suppressMirror; + + public TextFieldValueState(EntryHandler owner) + : base(ComposeExtensions.NewTextFieldValue()) + { + _owner = owner; + } + + // Programmatic write that updates the Compose state without + // bouncing the new value back through MAUI's IEntry.Text setter + // (which would feed-back into MapText). Used by MapText and + // ApplySelection where the value is already authoritative on + // the MAUI side. + public void SetWithoutMirror(TextFieldValue value) + { + _suppressMirror = true; + try { Value = value; } + finally { _suppressMirror = false; } + } + + public override TextFieldValue Value + { + get => base.Value; + set + { + var max = _owner._maxLength.Value; + if (max >= 0 && value?.Text is { } text && text.Length > max) + { + var trunc = text[..max]; + // Clamp existing selection within the new bounds. + var sel = value.Selection; + var start = Math.Min((int)sel, max); + var end = Math.Min((int)(sel >> 32), max); + value = ComposeExtensions.NewTextFieldValue( + trunc, TextRangeKt.TextRange(start, end), composition: null); + } + base.Value = value!; + if (!_suppressMirror && _owner.VirtualView is { } entry && value is not null) + { + var newText = value.Text ?? string.Empty; + if (entry.Text != newText) + entry.Text = newText; + var caret = (int)value.Selection; + var selLen = (int)(value.Selection >> 32) - caret; + if (entry.CursorPosition != caret) + entry.CursorPosition = caret; + if (entry.SelectionLength != selLen) + entry.SelectionLength = selLen; + } + } + } + } } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs index 0ef53e50..3d436457 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs @@ -1,5 +1,7 @@ using AndroidX.Compose; using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Text; +using AndroidX.Compose.UI.Text.Input; using Microsoft.AndroidX.Compose.Maui.Platform; using Microsoft.Maui.Handlers; using ComposeColor = AndroidX.Compose.Color; @@ -8,6 +10,7 @@ using ComposeKeyboardType = AndroidX.Compose.KeyboardType; using ComposeOutlinedTextField = AndroidX.Compose.OutlinedTextField; using ComposeText = AndroidX.Compose.Text; +using ComposeTextAlign = AndroidX.Compose.TextAlign; using ComposeTextStyle = AndroidX.Compose.TextStyle; namespace Microsoft.AndroidX.Compose.Maui.Handlers; @@ -31,6 +34,13 @@ namespace Microsoft.AndroidX.Compose.Maui.Handlers; /// styled text input with a search-icon leading slot, so we render /// the simpler OutlinedTextField shape and wire the search /// action through keyboardActions.onSearch. +/// +/// Like , value/cursor/selection state +/// is bound through Compose's +/// OutlinedTextField(MutableState<TextFieldValue>) +/// overload via a custom subclass that +/// enforces and mirrors text + +/// caret back to MAUI's . /// public partial class SearchBarHandler : ComposeElementHandler { @@ -42,34 +52,61 @@ public partial class SearchBarHandler : ComposeElementHandler public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IText.Text)] = MapText, - [nameof(ITextStyle.TextColor)] = MapTextColor, - [nameof(ITextStyle.Font)] = MapFont, - [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, - [nameof(ITextInput.Keyboard)] = MapKeyboard, - [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, - [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + [nameof(IText.Text)] = MapText, + [nameof(ITextStyle.TextColor)] = MapTextColor, + [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, + [nameof(ITextStyle.Font)] = MapFont, + [nameof(IPlaceholder.Placeholder)] = MapPlaceholder, + [nameof(IPlaceholder.PlaceholderColor)] = MapPlaceholderColor, + [nameof(ISearchBar.SearchIconColor)] = MapSearchIconColor, + [nameof(ISearchBar.CancelButtonColor)] = MapCancelButtonColor, + [nameof(ISearchBar.ReturnType)] = MapReturnType, + [nameof(ITextInput.Keyboard)] = MapKeyboard, + [nameof(ITextInput.IsReadOnly)] = MapIsReadOnly, + [nameof(ITextInput.IsSpellCheckEnabled)] = MapAutoCorrect, + [nameof(ITextInput.IsTextPredictionEnabled)] = MapAutoCorrect, + [nameof(ITextInput.MaxLength)] = MapMaxLength, + [nameof(ITextInput.CursorPosition)] = MapCursorPosition, + [nameof(ITextInput.SelectionLength)] = MapSelectionLength, + [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment, + [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + // TODO: VerticalTextAlignment — see EditorHandler; Box facade + // doesn't expose contentAlignment. }; /// Command mapper (inherits view-level commands; no extras). public static CommandMapper CommandMapper = new(ViewCommandMapper); - readonly MutableState _text = new(string.Empty); - readonly MutableState _color = new((long?)null); - readonly MutableState _fontSize = new((int?)null); - readonly MutableState _bold = new(false); - readonly MutableState _placeholder = new(string.Empty); - readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); - readonly MutableState _readOnly = new(false); - readonly MutableState _fillWidth = new(false); + readonly TextFieldValueState _tfv; + readonly MutableState _color = new((long?)null); + readonly MutableState _placeholderColor = new((long?)null); + readonly MutableState _searchIconColor = new((long?)null); + readonly MutableState _cancelButtonColor = new((long?)null); + readonly MutableState _fontSize = new((int?)null); + readonly MutableState _bold = new(false); + readonly MutableState _placeholder = new(string.Empty); + readonly MutableState _keyboardType = new(ComposeKeyboardType.Text); + readonly MutableState _readOnly = new(false); + readonly MutableState _autoCorrect = new(true); + readonly MutableState _imeAction = new(ComposeImeAction.Search); + readonly MutableState _maxLength = new(-1); + readonly MutableState _letterSpacing = new((float?)null); + readonly MutableState _hTextAlign = new((int)TextAlignment.Start); + readonly MutableState _fillWidth = new(false); /// Construct a handler with the default mappers. - public SearchBarHandler() : base(Mapper, CommandMapper) { } + public SearchBarHandler() : base(Mapper, CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// Construct a handler with custom mappers. public SearchBarHandler(IPropertyMapper? mapper, CommandMapper? commandMapper = null) - : base(mapper ?? Mapper, commandMapper ?? CommandMapper) { } + : base(mapper ?? Mapper, commandMapper ?? CommandMapper) + { + _tfv = new TextFieldValueState(this); + } /// public override ComposableNode BuildNode(IComposer composer) @@ -77,44 +114,88 @@ public override ComposableNode BuildNode(IComposer composer) var virtualView = VirtualView ?? throw new InvalidOperationException("VirtualView not set on SearchBarHandler."); - var packed = _color.Value; - var size = _fontSize.Value; - var bold = _bold.Value; - var placeholder = _placeholder.Value; - var keyboardType = _keyboardType.Value; - var readOnly = _readOnly.Value; - var fill = _fillWidth.Value; + var packed = _color.Value; + var placeholderInk = _placeholderColor.Value; + var searchIconInk = _searchIconColor.Value; + var cancelButtonInk = _cancelButtonColor.Value; + var size = _fontSize.Value; + var bold = _bold.Value; + var placeholder = _placeholder.Value; + var keyboardType = _keyboardType.Value; + var readOnly = _readOnly.Value; + var autoCorrect = _autoCorrect.Value; + var imeAction = _imeAction.Value; + var letterSpacing = _letterSpacing.Value; + var hTextAlign = (TextAlignment)_hTextAlign.Value; + var fill = _fillWidth.Value; - var field = new ComposeOutlinedTextField(_text.Value, OnValueChanged) + var field = new ComposeOutlinedTextField(_tfv) { ReadOnly = readOnly, SingleLine = true, // Magnifier glass — pure Unicode keeps us off the - // material-icons NuGet for the MAUI base layer. - LeadingIcon = new ComposeText("\U0001F50D"), + // material-icons NuGet for the MAUI base layer. Coloured + // via SearchIconColor when set. + LeadingIcon = new ComposeText("\U0001F50D") + { + Color = searchIconInk.HasValue ? new ComposeColor(searchIconInk.Value) : null, + }, }; if (!string.IsNullOrEmpty(placeholder)) - field.Placeholder = new ComposeText(placeholder); - if (packed.HasValue || size.HasValue || bold) + field.Placeholder = new ComposeText(placeholder) + { + Color = placeholderInk.HasValue ? new ComposeColor(placeholderInk.Value) : null, + }; + if (packed.HasValue || size.HasValue || bold || letterSpacing.HasValue + || hTextAlign != TextAlignment.Start) field.TextStyle = new ComposeTextStyle { - Color = packed.HasValue ? new ComposeColor(packed.Value) : null, - FontSize = size.HasValue ? new Sp(size.Value) : null, - FontWeight = bold ? ComposeFontWeight.Bold : null, + Color = packed.HasValue ? new ComposeColor(packed.Value) : null, + FontSize = size.HasValue ? new Sp(size.Value) : null, + FontWeight = bold ? ComposeFontWeight.Bold : null, + LetterSpacing = letterSpacing.HasValue ? new Sp(1) * letterSpacing.Value : null, + TextAlign = hTextAlign switch + { + TextAlignment.Center => ComposeTextAlign.Center, + TextAlignment.End => ComposeTextAlign.End, + _ => null, + }, }; + // Cancel-X trailing IconButton, visible only when the field has + // content — tapping clears the buffer. CancelButtonColor tints + // the X glyph when set. + if (!string.IsNullOrEmpty(_tfv.Value?.Text)) + { + field.TrailingIcon = new IconButton(onClick: () => + { + if (VirtualView is { } searchBar) + searchBar.Text = string.Empty; + }) + { + new ComposeText("\u2715") + { + Color = cancelButtonInk.HasValue ? new ComposeColor(cancelButtonInk.Value) : null, + }, + }; + } + // KeyboardOptions: route through KeyboardOptionsCompanion.Default.Copy - // so we override imeAction without disturbing other slots. + // so we override imeAction (typically Search) without disturbing + // other slots — same pattern as EntryHandler. var d = KeyboardOptionsCompanion.Default; field.KeyboardOptions = d.Copy( - d.Capitalization, d.AutoCorrectEnabled, - keyboardType, ComposeImeAction.Search, + d.Capitalization, (Java.Lang.Boolean)autoCorrect, + keyboardType, imeAction, d.PlatformImeOptions, d.ShowKeyboardOnFocus, d.HintLocales); // Wire the search-key callback. SearchButtonPressed() is // MAUI's standard trampoline — it fires SearchCommand and the // SearchButtonPressed event the way the stock handler does. field.KeyboardActions = KeyboardActionsHelper.Create( - onSearch: OnSearchInvoked); + onSearch: OnSearchInvoked, + onDone: OnSearchInvoked, + onGo: OnSearchInvoked, + onSend: OnSearchInvoked); var modifier = (fill ? Modifier.FillMaxWidth() : Modifier.Companion) .ApplyGestures(virtualView, MauiContext) @@ -123,15 +204,6 @@ public override ComposableNode BuildNode(IComposer composer) return field; } - void OnValueChanged(string newValue) - { - // Mirror EntryHandler's two-way pattern (issue: feedback-loop - // guard via MutableState equality). - _text.Value = newValue; - if (VirtualView is { } searchBar) - searchBar.Text = newValue; - } - void OnSearchInvoked() { // SearchButtonPressed is the public ISearchBar trampoline: @@ -141,13 +213,27 @@ void OnSearchInvoked() } /// Map to the Compose value slot. - public static void MapText(SearchBarHandler handler, ISearchBar searchBar) => - handler._text.Value = searchBar.Text ?? string.Empty; + public static void MapText(SearchBarHandler handler, ISearchBar searchBar) + { + var newText = searchBar.Text ?? string.Empty; + var current = handler._tfv.Value; + if (current?.Text == newText) return; + var cursor = Math.Min(searchBar.CursorPosition, newText.Length); + if (cursor < 0) cursor = newText.Length; + handler._tfv.SetWithoutMirror(ComposeExtensions.NewTextFieldValue( + newText, TextRangeKt.TextRange(cursor), composition: null)); + } /// Map to the Compose TextStyle.Color slot. public static void MapTextColor(SearchBarHandler handler, ISearchBar searchBar) => handler._color.Value = ColorMapping.ToPackedLong(searchBar.TextColor); + /// Map to Compose letterSpacing. + public static void MapCharacterSpacing(SearchBarHandler handler, ISearchBar searchBar) => + handler._letterSpacing.Value = searchBar.CharacterSpacing != 0 + ? (float)searchBar.CharacterSpacing + : null; + /// Map (size + bold) to Compose TextStyle slots. public static void MapFont(SearchBarHandler handler, ISearchBar searchBar) { @@ -161,6 +247,32 @@ public static void MapFont(SearchBarHandler handler, ISearchBar searchBar) public static void MapPlaceholder(SearchBarHandler handler, ISearchBar searchBar) => handler._placeholder.Value = searchBar.Placeholder ?? string.Empty; + /// Map to the placeholder Text's color. + public static void MapPlaceholderColor(SearchBarHandler handler, ISearchBar searchBar) => + handler._placeholderColor.Value = ColorMapping.ToPackedLong(searchBar.PlaceholderColor); + + /// Map to the leading magnifier's color. + public static void MapSearchIconColor(SearchBarHandler handler, ISearchBar searchBar) => + handler._searchIconColor.Value = ColorMapping.ToPackedLong(searchBar.SearchIconColor); + + /// Map to the trailing X's color. + public static void MapCancelButtonColor(SearchBarHandler handler, ISearchBar searchBar) => + handler._cancelButtonColor.Value = ColorMapping.ToPackedLong(searchBar.CancelButtonColor); + + /// Map to KeyboardOptions.imeAction. + public static void MapReturnType(SearchBarHandler handler, ISearchBar searchBar) => + handler._imeAction.Value = searchBar.ReturnType switch + { + ReturnType.Done => ComposeImeAction.Done, + ReturnType.Go => ComposeImeAction.Go, + ReturnType.Next => ComposeImeAction.Next, + ReturnType.Send => ComposeImeAction.Send, + // Default for SearchBar is the magnifier (Search) — even + // for ReturnType.Default — so the IME button matches the + // leading icon. + _ => ComposeImeAction.Search, + }; + /// Map to a Compose KeyboardType int. public static void MapKeyboard(SearchBarHandler handler, ISearchBar searchBar) => handler._keyboardType.Value = KeyboardMapping.Resolve(searchBar.Keyboard, nameof(SearchBarHandler)); @@ -169,6 +281,52 @@ public static void MapKeyboard(SearchBarHandler handler, ISearchBar searchBar) = public static void MapIsReadOnly(SearchBarHandler handler, ISearchBar searchBar) => handler._readOnly.Value = searchBar.IsReadOnly; + /// + /// Combined map for + + /// . Compose has a + /// single autoCorrectEnabled slot; we AND the MAUI flags. + /// + public static void MapAutoCorrect(SearchBarHandler handler, ISearchBar searchBar) => + handler._autoCorrect.Value = searchBar.IsSpellCheckEnabled && searchBar.IsTextPredictionEnabled; + + /// Map to the truncation cap (negative = unlimited). + public static void MapMaxLength(SearchBarHandler handler, ISearchBar searchBar) + { + handler._maxLength.Value = searchBar.MaxLength; + if (searchBar.MaxLength >= 0 + && handler._tfv.Value is { } current + && (current.Text?.Length ?? 0) > searchBar.MaxLength) + { + handler._tfv.Value = current; + } + } + + /// Map to the TFV selection start. + public static void MapCursorPosition(SearchBarHandler handler, ISearchBar searchBar) => + ApplySelection(handler, searchBar); + + /// Map to the TFV selection end. + public static void MapSelectionLength(SearchBarHandler handler, ISearchBar searchBar) => + ApplySelection(handler, searchBar); + + static void ApplySelection(SearchBarHandler handler, ISearchBar searchBar) + { + var current = handler._tfv.Value; + if (current is null) return; + var text = current.Text ?? string.Empty; + var start = Math.Clamp(searchBar.CursorPosition, 0, text.Length); + var length = Math.Max(0, Math.Min(searchBar.SelectionLength, text.Length - start)); + var end = start + length; + var existing = current.Selection; + if ((int)existing == start && (int)(existing >> 32) == end) + return; + handler._tfv.SetWithoutMirror(current.Copy(text, TextRangeKt.TextRange(start, end), composition: null)); + } + + /// Map to Compose textAlign. + public static void MapHorizontalTextAlignment(SearchBarHandler handler, ISearchBar searchBar) => + handler._hTextAlign.Value = (int)searchBar.HorizontalTextAlignment; + /// /// Map to /// Modifier.fillMaxWidth() when the search bar asks to @@ -177,4 +335,61 @@ public static void MapIsReadOnly(SearchBarHandler handler, ISearchBar searchBar) public static void MapHorizontalLayoutAlignment(SearchBarHandler handler, ISearchBar searchBar) => handler._fillWidth.Value = searchBar.HorizontalLayoutAlignment == Microsoft.Maui.Primitives.LayoutAlignment.Fill; + + /// + /// of + /// that intercepts every write so we can enforce + /// and mirror text + caret back + /// to MAUI's . Mirrors the + /// pattern. + /// + sealed class TextFieldValueState : MutableState + { + readonly SearchBarHandler _owner; + bool _suppressMirror; + + public TextFieldValueState(SearchBarHandler owner) + : base(ComposeExtensions.NewTextFieldValue()) + { + _owner = owner; + } + + public void SetWithoutMirror(TextFieldValue value) + { + _suppressMirror = true; + try { Value = value; } + finally { _suppressMirror = false; } + } + + public override TextFieldValue Value + { + get => base.Value; + set + { + var max = _owner._maxLength.Value; + if (max >= 0 && value?.Text is { } text && text.Length > max) + { + var trunc = text[..max]; + var sel = value.Selection; + var start = Math.Min((int)sel, max); + var end = Math.Min((int)(sel >> 32), max); + value = ComposeExtensions.NewTextFieldValue( + trunc, TextRangeKt.TextRange(start, end), composition: null); + } + base.Value = value!; + if (!_suppressMirror && _owner.VirtualView is { } searchBar && value is not null) + { + var newText = value.Text ?? string.Empty; + if (searchBar.Text != newText) + searchBar.Text = newText; + var caret = (int)value.Selection; + var selLen = (int)(value.Selection >> 32) - caret; + if (searchBar.CursorPosition != caret) + searchBar.CursorPosition = caret; + if (searchBar.SelectionLength != selLen) + searchBar.SelectionLength = selLen; + } + } + } + } } From 30d6f4e9ba86ab766fd422302ff52e191bc7ae29 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 15 Jun 2026 11:04:08 -0500 Subject: [PATCH 5/7] Add gallery demos exercising new MAUI mapper keys For each of the new keys wired in this PR, add a XAML section to the existing demo page that exercises the mapper visibly: - EntriesPage: CharacterSpacing, HorizontalTextAlignment (Start/Center/End), MaxLength (with overflow-rejection echo), ClearButtonVisibility, ReturnType (Done/Go/Next/Search/Send), IsSpellCheckEnabled+IsTextPredictionEnabled, PlaceholderColor, CursorPosition+SelectionLength (button-driven caret control). - EditorPage: CharacterSpacing, HorizontalTextAlignment Center+End, IsSpellCheckEnabled+IsTextPredictionEnabled, PlaceholderColor, CursorPosition+SelectionLength. - SearchPage: CharacterSpacing, HorizontalTextAlignment, MaxLength, IsSpellCheckEnabled+IsTextPredictionEnabled, PlaceholderColor, SearchIconColor, CancelButtonColor, ReturnType. - ButtonsPage: CharacterSpacing, CornerRadius (Pill / Sharp), Font (large bold), Padding, StrokeColor+StrokeThickness (outlined / heavy), ImageSource (dotnet_bot). - LabelsPage: CharacterSpacing, LineHeight (vs default), Padding chip, TextDecorations (Underline / Strikethrough / both). - PickersPage: Picker.CharacterSpacing, Picker.HorizontalTextAlignment, DatePicker.CharacterSpacing, TimePicker.CharacterSpacing. - IndicatorPage: HideSingle (True hides single dot, False keeps it), MaximumVisible=3 with Count=10. - RefreshPage: IsRefreshEnabled toggle that disables the pull gesture. Spot-checked on a Pixel device: the app starts cleanly, Buttons + Labels demos render the new mapper effects visibly (CornerRadius pill/sharp, StrokeColor outlined/heavy, ImageSource leading icon, CharacterSpacing wide letters, LineHeight 2x vs default, TextDecorations underline/strikethrough). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/ButtonsPage.xaml | 69 ++++++++++++++++++ .../Pages/EditorPage.xaml | 43 +++++++++++ .../Pages/EditorPage.xaml.cs | 29 ++++++++ .../Pages/EntriesPage.xaml | 71 +++++++++++++++++++ .../Pages/EntriesPage.xaml.cs | 40 +++++++++++ .../Pages/IndicatorPage.xaml | 33 +++++++++ .../Pages/LabelsPage.xaml | 29 ++++++++ .../Pages/PickersPage.xaml | 32 +++++++++ .../Pages/RefreshPage.xaml | 13 ++++ .../Pages/RefreshPage.xaml.cs | 6 ++ .../Pages/SearchPage.xaml | 37 ++++++++++ 11 files changed, 402 insertions(+) diff --git a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml index 8c0811ae..eca77173 100644 --- a/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml +++ b/src/Microsoft.AndroidX.Compose.Maui.Sample/Pages/ButtonsPage.xaml @@ -64,6 +64,75 @@ x:Name="ClickCountLabel" Text="No clicks yet" /> + + public static void MapCharacterSpacing(DatePickerHandler handler, IDatePicker dp) => - handler._letterSpacing.Value = dp.CharacterSpacing > 0 ? (float)dp.CharacterSpacing : null; + handler._letterSpacing.Value = dp.CharacterSpacing != 0 ? (float)dp.CharacterSpacing : null; /// /// Map to the Compose dialog's diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs index 7e8b01bc..069ecbaf 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs @@ -308,7 +308,9 @@ public override TextFieldValue Value value = ComposeExtensions.NewTextFieldValue( trunc, TextRangeKt.TextRange(start, end), composition: null); } - base.Value = value!; + base.Value = value + ?? throw new InvalidOperationException( + "TextFieldValue cannot be null on EditorHandler.TextFieldValueState."); if (!_suppressMirror && _owner.VirtualView is { } editor && value is not null) { var newText = value.Text ?? string.Empty; diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs index 45644484..12dcef1e 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs @@ -392,7 +392,9 @@ public override TextFieldValue Value value = ComposeExtensions.NewTextFieldValue( trunc, TextRangeKt.TextRange(start, end), composition: null); } - base.Value = value!; + base.Value = value + ?? throw new InvalidOperationException( + "TextFieldValue cannot be null on EntryHandler.TextFieldValueState."); if (!_suppressMirror && _owner.VirtualView is { } entry && value is not null) { var newText = value.Text ?? string.Empty; diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs index 1093e76c..43baf791 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs @@ -145,16 +145,19 @@ _ when (decorations & Microsoft.Maui.TextDecorations.Underline) != 0 // Single PrependModifier call combining the layout-fill (if // applicable) with the cross-cutting view properties — calling // PrependModifier twice would replace, not merge, so this - // builds the chain once. + // builds the chain once. View properties are applied on the + // outermost modifier (per ModifierBridge convention) so + // background/shadow/opacity cover the entire label rectangle; + // padding is innermost so it only shrinks the content area. var outer = (fill ? Modifier.FillMaxWidth() : Modifier.Companion) + .ApplyViewProperties(virtualView) + .ApplyGestures(virtualView, MauiContext) + .ApplySemantics(virtualView) .Padding( new Dp((float)padding.Left), new Dp((float)padding.Top), new Dp((float)padding.Right), - new Dp((float)padding.Bottom)) - .ApplyViewProperties(virtualView) - .ApplyGestures(virtualView, MauiContext) - .ApplySemantics(virtualView); + new Dp((float)padding.Bottom)); text.PrependModifier(outer); return text; } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs index 1475203e..1c85d998 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs @@ -186,5 +186,5 @@ public static void MapFont(RadioButtonHandler handler, IRadioButton rb) /// int-only constructor would round to zero. /// public static void MapCharacterSpacing(RadioButtonHandler handler, IRadioButton rb) => - handler._letterSpacing.Value = rb.CharacterSpacing > 0 ? (float)rb.CharacterSpacing : null; + handler._letterSpacing.Value = rb.CharacterSpacing != 0 ? (float)rb.CharacterSpacing : null; } diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs index 3d436457..a6edc254 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs @@ -376,7 +376,9 @@ public override TextFieldValue Value value = ComposeExtensions.NewTextFieldValue( trunc, TextRangeKt.TextRange(start, end), composition: null); } - base.Value = value!; + base.Value = value + ?? throw new InvalidOperationException( + "TextFieldValue cannot be null on SearchBarHandler.TextFieldValueState."); if (!_suppressMirror && _owner.VirtualView is { } searchBar && value is not null) { var newText = value.Text ?? string.Empty; diff --git a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs index b4cdf014..0fe29539 100644 --- a/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs +++ b/src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs @@ -216,7 +216,7 @@ public static void MapFont(TimePickerHandler handler, ITimePicker tp) /// letterSpacing slot in . /// public static void MapCharacterSpacing(TimePickerHandler handler, ITimePicker tp) => - handler._letterSpacing.Value = tp.CharacterSpacing > 0 ? (float)tp.CharacterSpacing : null; + handler._letterSpacing.Value = tp.CharacterSpacing != 0 ? (float)tp.CharacterSpacing : null; /// /// Map to the Compose dialog's diff --git a/threads.json b/threads.json new file mode 100644 index 00000000..8d117af6 --- /dev/null +++ b/threads.json @@ -0,0 +1 @@ +{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOSj9ml86JRQla","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs","line":157,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeGo","databaseId":3406684584,"author":{"login":"copilot-pull-request-reviewer"},"body":"Padding is applied **before** `ApplyViewProperties` in the modifier chain, which means background/shadow/opacity will only paint the area *inside* the padding, not the full label rectangle. This contradicts the established convention in the codebase: `ModifierBridge` docs (line 13: \"every Compose-backed handler chains `ApplyViewProperties` on its **outermost** modifier\") and `LayoutHandler` (line 133–140) explicitly documents view-properties outermost, padding innermost.\n\nThe correct ordering should place padding after the view properties chain so that MAUI's `Label.Background` covers the entire label area including padding (matching stock MAUI semantics).","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684584"}]}},{"id":"PRRT_kwDOSj9ml86JRQlm","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs","line":219,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeG1","databaseId":3406684597,"author":{"login":"copilot-pull-request-reviewer"},"body":"This uses `> 0` to gate letter spacing, but the other handlers in this PR (`LabelHandler`, `EntryHandler`, `EditorHandler`, `SearchBarHandler`, `PickerHandler`, `ButtonHandler`) all use `!= 0`. MAUI's `CharacterSpacing` is a `double` that can be negative (to tighten characters). Using `> 0` silently drops negative values, which is inconsistent with the majority of handlers. Use `!= 0` for consistency and to support negative spacing.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684597"}]}},{"id":"PRRT_kwDOSj9ml86JRQlt","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs","line":338,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHB","databaseId":3406684609,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same inconsistency as `TimePickerHandler` — uses `> 0` but the other six handlers in this PR use `!= 0`. MAUI's `CharacterSpacing` can be negative (tighter kerning). Use `!= 0` for consistency.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684609"}]}},{"id":"PRRT_kwDOSj9ml86JRQl3","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs","line":189,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHP","databaseId":3406684623,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same inconsistency — uses `> 0` but the other six handlers in this PR use `!= 0`. MAUI's `CharacterSpacing` can be negative. Use `!= 0` for consistency.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684623"}]}},{"id":"PRRT_kwDOSj9ml86JRQmB","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs","line":395,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHg","databaseId":3406684640,"author":{"login":"copilot-pull-request-reviewer"},"body":"The `!` (null-forgiving postfix) operator is prohibited by the coding guidelines. Since `value` can be null here (the setter parameter is nullable and the code checks `value is not null` two lines below), use the `?? throw` pattern instead.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684640"}]}},{"id":"PRRT_kwDOSj9ml86JRQmM","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs","line":311,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHv","databaseId":3406684655,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same `!` operator violation as `EntryHandler.TextFieldValueState`. The coding guidelines require `?? throw new InvalidOperationException(...)` instead of null-forgiving `!`.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684655"}]}},{"id":"PRRT_kwDOSj9ml86JRQmW","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs","line":379,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeH6","databaseId":3406684666,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same `!` operator violation as `EntryHandler.TextFieldValueState`. The coding guidelines require `?? throw new InvalidOperationException(...)` instead of null-forgiving `!`.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684666"}]}}]}}}}} \ No newline at end of file From a456c7e51cc9b204b24aaf32dee55616fa869d87 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 15 Jun 2026 11:11:28 -0500 Subject: [PATCH 7/7] Remove threads.json debug artifact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- threads.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 threads.json diff --git a/threads.json b/threads.json deleted file mode 100644 index 8d117af6..00000000 --- a/threads.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"PRRT_kwDOSj9ml86JRQla","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/LabelHandler.cs","line":157,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeGo","databaseId":3406684584,"author":{"login":"copilot-pull-request-reviewer"},"body":"Padding is applied **before** `ApplyViewProperties` in the modifier chain, which means background/shadow/opacity will only paint the area *inside* the padding, not the full label rectangle. This contradicts the established convention in the codebase: `ModifierBridge` docs (line 13: \"every Compose-backed handler chains `ApplyViewProperties` on its **outermost** modifier\") and `LayoutHandler` (line 133–140) explicitly documents view-properties outermost, padding innermost.\n\nThe correct ordering should place padding after the view properties chain so that MAUI's `Label.Background` covers the entire label area including padding (matching stock MAUI semantics).","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684584"}]}},{"id":"PRRT_kwDOSj9ml86JRQlm","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/TimePickerHandler.cs","line":219,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeG1","databaseId":3406684597,"author":{"login":"copilot-pull-request-reviewer"},"body":"This uses `> 0` to gate letter spacing, but the other handlers in this PR (`LabelHandler`, `EntryHandler`, `EditorHandler`, `SearchBarHandler`, `PickerHandler`, `ButtonHandler`) all use `!= 0`. MAUI's `CharacterSpacing` is a `double` that can be negative (to tighten characters). Using `> 0` silently drops negative values, which is inconsistent with the majority of handlers. Use `!= 0` for consistency and to support negative spacing.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684597"}]}},{"id":"PRRT_kwDOSj9ml86JRQlt","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/DatePickerHandler.cs","line":338,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHB","databaseId":3406684609,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same inconsistency as `TimePickerHandler` — uses `> 0` but the other six handlers in this PR use `!= 0`. MAUI's `CharacterSpacing` can be negative (tighter kerning). Use `!= 0` for consistency.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684609"}]}},{"id":"PRRT_kwDOSj9ml86JRQl3","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/RadioButtonHandler.cs","line":189,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHP","databaseId":3406684623,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same inconsistency — uses `> 0` but the other six handlers in this PR use `!= 0`. MAUI's `CharacterSpacing` can be negative. Use `!= 0` for consistency.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684623"}]}},{"id":"PRRT_kwDOSj9ml86JRQmB","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/EntryHandler.cs","line":395,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHg","databaseId":3406684640,"author":{"login":"copilot-pull-request-reviewer"},"body":"The `!` (null-forgiving postfix) operator is prohibited by the coding guidelines. Since `value` can be null here (the setter parameter is nullable and the code checks `value is not null` two lines below), use the `?? throw` pattern instead.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684640"}]}},{"id":"PRRT_kwDOSj9ml86JRQmM","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/EditorHandler.cs","line":311,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeHv","databaseId":3406684655,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same `!` operator violation as `EntryHandler.TextFieldValueState`. The coding guidelines require `?? throw new InvalidOperationException(...)` instead of null-forgiving `!`.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684655"}]}},{"id":"PRRT_kwDOSj9ml86JRQmW","isResolved":false,"isOutdated":false,"path":"src/Microsoft.AndroidX.Compose.Maui/Handlers/SearchBarHandler.cs","line":379,"comments":{"nodes":[{"id":"PRRC_kwDOSj9ml87LDeH6","databaseId":3406684666,"author":{"login":"copilot-pull-request-reviewer"},"body":"Same `!` operator violation as `EntryHandler.TextFieldValueState`. The coding guidelines require `?? throw new InvalidOperationException(...)` instead of null-forgiving `!`.","url":"https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/273#discussion_r3406684666"}]}}]}}}}} \ No newline at end of file