From ade100d1c8c4d7241142804a55aaae65c696558e Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Tue, 10 Mar 2026 19:59:36 -0500 Subject: [PATCH 1/2] Fix Comet view tree traversal and tap handling - CometViewResolver: Handle AmbiguousMatchException from generic handlers (ViewHandler) by walking inheritance chain with DeclaredOnly flag. Add outer try/catch to prevent unhandled exceptions from aborting tree walks. - VisualTreeWalker: Extract visibility, bounds, opacity, and text from IView/IText interfaces for non-VisualElement types (Comet). Use ??= for text extraction to preserve IText-resolved values. - DevFlowAgentService: Add interface-based tap handling (IButton, ISwitch, ICheckBox, IRadioButton) for Comet views that implement MAUI interfaces but not Controls classes. Add TryNativeTapOnHandler fallback using handler PlatformView reflection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CometViewResolver.cs | 403 ++++++++++++++++++ .../DevFlowAgentService.cs | 65 +++ .../VisualTreeWalker.cs | 52 ++- 3 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 src/MauiDevFlow.Agent.Core/CometViewResolver.cs diff --git a/src/MauiDevFlow.Agent.Core/CometViewResolver.cs b/src/MauiDevFlow.Agent.Core/CometViewResolver.cs new file mode 100644 index 0000000..1489c83 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/CometViewResolver.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Maui; + +namespace MauiDevFlow.Agent.Core; + +/// +/// Resolves Comet view types from CometView wrappers using reflection. +/// No hard reference to Comet required — uses runtime type checking. +/// +internal static class CometViewResolver +{ + private static bool? _cometAvailable; + private static Type? _cometViewType; + private static Type? _cometHandlerType; + private static MethodInfo? _getViewMethod; + private static PropertyInfo? _builtViewProperty; + private static PropertyInfo? _bodyProperty; + private static PropertyInfo? _currentViewProperty; + + /// + /// Checks if Comet is loaded in the current app domain. + /// Caches the result and reflection metadata for performance. + /// + private static bool IsCometAvailable() + { + if (_cometAvailable.HasValue) + return _cometAvailable.Value; + + try + { + // Look for Comet assembly and core types via reflection + var cometAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Comet"); + + if (cometAssembly == null) + { + _cometAvailable = false; + return false; + } + + // Cache Comet.View type + _cometViewType = cometAssembly.GetType("Comet.View"); + if (_cometViewType == null) + { + _cometAvailable = false; + return false; + } + + // Cache CometViewHandler type (in Comet.Handlers namespace) + _cometHandlerType = cometAssembly.GetType("Comet.Handlers.CometViewHandler"); + + // Cache reflection metadata for key properties/methods + _getViewMethod = _cometViewType.GetMethod("GetView", BindingFlags.Public | BindingFlags.Instance); + _builtViewProperty = _cometViewType.GetProperty("BuiltView", BindingFlags.Public | BindingFlags.Instance); + _bodyProperty = _cometViewType.GetProperty("Body", BindingFlags.Public | BindingFlags.Instance); + + // Cache platform-specific CometView (iOS/Android/Windows) CurrentView property + // Platform views are in Comet.iOS.CometView, Comet.Droid.CometView, etc. + var platformCometTypes = new[] + { + cometAssembly.GetType("Comet.iOS.CometView"), + cometAssembly.GetType("Comet.Droid.CometView"), + cometAssembly.GetType("Comet.Windows.CometView"), + }; + + foreach (var platformType in platformCometTypes) + { + if (platformType != null) + { + _currentViewProperty = platformType.GetProperty("CurrentView", BindingFlags.Public | BindingFlags.Instance); + if (_currentViewProperty != null) + break; + } + } + + _cometAvailable = true; + return true; + } + catch + { + _cometAvailable = false; + return false; + } + } + + /// + /// Safely resolves a property by name, handling AmbiguousMatchException + /// from generic handler types (e.g. Comet's ViewHandler<View, CometView>). + /// Falls back to walking the type hierarchy with DeclaredOnly. + /// + public static PropertyInfo? GetPropertySafe(Type type, string name) + { + try + { + return type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); + } + catch (AmbiguousMatchException) + { + // Walk inheritance chain with DeclaredOnly to resolve ambiguity + var current = type; + while (current != null) + { + try + { + var prop = current.GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + if (prop != null) return prop; + } + catch { /* skip this level */ } + current = current.BaseType; + } + return null; + } + } + + /// + /// Attempts to resolve a Comet view from a platform view or handler. + /// Returns a tuple of (resolvedType, resolvedFullType, cometViewInstance, additionalProperties). + /// Returns null if not a Comet view. + /// + public static (string Type, string FullType, object CometView, Dictionary Properties)? TryResolveCometView(IVisualTreeElement element) + { + if (!IsCometAvailable()) + return null; + + // Try to get the Comet virtual view from the element + object? cometView = null; + + try + { + // Strategy 1: Element is directly a Comet.View + if (_cometViewType != null && _cometViewType.IsInstanceOfType(element)) + { + cometView = element; + } + // Strategy 2: Element has a Handler that's CometViewHandler + else if (element is IView view && view.Handler != null && + _cometHandlerType != null && + _cometHandlerType.IsInstanceOfType(view.Handler)) + { + // Get VirtualView from handler — use safe reflection to avoid + // AmbiguousMatchException on generic handlers like ViewHandler + var virtualViewProp = GetPropertySafe(view.Handler.GetType(), "VirtualView"); + cometView = virtualViewProp?.GetValue(view.Handler); + } + // Strategy 3: Element's platform view is CometView wrapper + else if (element is IView view2 && view2.Handler != null) + { + var platformViewProp = GetPropertySafe(view2.Handler.GetType(), "PlatformView"); + var platformView = platformViewProp?.GetValue(view2.Handler); + if (platformView != null && _currentViewProperty != null) + { + // Check if platform view has CurrentView property (iOS/Android/Windows CometView) + var currentViewProp = platformView.GetType().GetProperty("CurrentView", BindingFlags.Public | BindingFlags.Instance); + if (currentViewProp != null) + { + cometView = currentViewProp.GetValue(platformView); + } + } + } + } + catch + { + // If any reflection fails, this isn't a Comet view we can resolve + return null; + } + + if (cometView == null || _cometViewType == null || !_cometViewType.IsInstanceOfType(cometView)) + return null; + + // Now resolve the actual Comet control type + var resolvedType = ResolveCometType(cometView); + var additionalProps = ExtractCometProperties(cometView); + + return (resolvedType.Type, resolvedType.FullType, cometView, additionalProps); + } + + /// + /// Resolves the actual Comet control type by unwrapping Body chains. + /// Returns the most specific type (Button, VStack, Component<T>, etc.) + /// + private static (string Type, string FullType) ResolveCometType(object cometView) + { + if (cometView == null) + return ("Unknown", "Comet.Unknown"); + + var viewType = cometView.GetType(); + + // Check if view has a Body — if so, resolve to BuiltView/GetView() + var hasBody = _bodyProperty?.GetValue(cometView) != null; + + if (hasBody) + { + // Try BuiltView first (faster, cached) + object? builtView = null; + if (_builtViewProperty != null) + { + try + { + builtView = _builtViewProperty.GetValue(cometView); + } + catch { /* BuiltView may throw if not yet built */ } + } + + // Fallback to GetView() if BuiltView is null + if (builtView == null && _getViewMethod != null) + { + try + { + builtView = _getViewMethod.Invoke(cometView, null); + } + catch { /* GetView() may throw */ } + } + + // If we got a built view, recurse to resolve its type + if (builtView != null && builtView != cometView) + { + return ResolveCometType(builtView); + } + } + + // No Body or couldn't resolve — use the view's actual type + var typeName = viewType.Name; + var fullTypeName = viewType.FullName ?? typeName; + + // For Component or Component, show generic parameters + if (viewType.IsGenericType) + { + var genericArgs = viewType.GetGenericArguments(); + if (genericArgs.Length > 0) + { + var argNames = string.Join(", ", genericArgs.Select(t => t.Name)); + typeName = $"{viewType.Name.Split('`')[0]}<{argNames}>"; + fullTypeName = $"{viewType.Namespace}.{typeName}"; + } + } + + return (typeName, fullTypeName); + } + + /// + /// Extracts Comet-specific properties (environment, state, etc.) for display. + /// + private static Dictionary ExtractCometProperties(object cometView) + { + var props = new Dictionary(); + + try + { + var viewType = cometView.GetType(); + + // Check if it's a Component with State + var stateProperty = viewType.GetProperty("State", BindingFlags.Public | BindingFlags.Instance); + if (stateProperty != null) + { + var stateValue = stateProperty.GetValue(cometView); + if (stateValue != null) + { + props["CometState"] = stateValue.GetType().Name; + } + } + + // Check if it's a Component with Props + var propsProperty = viewType.GetProperty("Props", BindingFlags.Public | BindingFlags.Instance); + if (propsProperty != null) + { + var propsValue = propsProperty.GetValue(cometView); + if (propsValue != null) + { + props["CometProps"] = propsValue.GetType().Name; + } + } + + // Get Body status + if (_bodyProperty != null) + { + var bodyValue = _bodyProperty.GetValue(cometView); + props["CometHasBody"] = (bodyValue != null).ToString(); + } + + // Get Id (every Comet.View has an Id property) + var idProperty = viewType.GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + if (idProperty != null) + { + var idValue = idProperty.GetValue(cometView); + if (idValue != null) + { + props["CometId"] = idValue.ToString() ?? "null"; + } + } + } + catch + { + // Ignore reflection errors + } + + return props; + } + + /// + /// Gets the list of environment keys defined in Comet.EnvironmentKeys. + /// Returns empty list if Comet not available or reflection fails. + /// + public static List GetEnvironmentKeys() + { + var keys = new List(); + + if (!IsCometAvailable()) + return keys; + + try + { + var cometAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Comet"); + + if (cometAssembly == null) + return keys; + + var envKeysType = cometAssembly.GetType("Comet.EnvironmentKeys"); + if (envKeysType == null) + return keys; + + // Get all public static string fields (environment key constants) + var fields = envKeysType.GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(string)); + + foreach (var field in fields) + { + var value = field.GetValue(null) as string; + if (!string.IsNullOrEmpty(value)) + keys.Add(value); + } + + // Also check nested classes (Fonts, Colors, etc.) + var nestedTypes = envKeysType.GetNestedTypes(BindingFlags.Public); + foreach (var nestedType in nestedTypes) + { + var nestedFields = nestedType.GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(string)); + + foreach (var field in nestedFields) + { + var value = field.GetValue(null) as string; + if (!string.IsNullOrEmpty(value)) + keys.Add(value); + } + } + } + catch + { + // Ignore reflection errors + } + + return keys; + } + + /// + /// Gets environment values from a Comet view. + /// Returns dictionary of key -> value (as string). + /// + public static Dictionary GetEnvironmentValues(object cometView) + { + var values = new Dictionary(); + + if (cometView == null || _cometViewType == null || !_cometViewType.IsInstanceOfType(cometView)) + return values; + + try + { + // Comet views have a GetEnvironment(string key) method + var getEnvMethod = _cometViewType.GetMethod("GetEnvironment", BindingFlags.Public | BindingFlags.Instance); + if (getEnvMethod == null) + return values; + + // Get all environment keys and query each one + var keys = GetEnvironmentKeys(); + foreach (var key in keys) + { + try + { + // Call GetEnvironment(key) + var genericMethod = getEnvMethod.MakeGenericMethod(typeof(object)); + var value = genericMethod.Invoke(cometView, new object[] { key }); + if (value != null) + { + values[key] = value.ToString() ?? "null"; + } + } + catch + { + // Ignore per-key errors + } + } + } + catch + { + // Ignore reflection errors + } + + return values; + } +} diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 721f77e..95e19fc 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -1189,6 +1189,25 @@ private async Task HandleTap(HttpRequest request) return "ok"; return $"No tap handler on {el.GetType().FullName} (gestures:{v.GestureRecognizers.Count}, type:{v.GetType().Name})"; + // Comet views implement MAUI interfaces (IButton, ISwitch, etc.) + // but not Microsoft.Maui.Controls classes, so handle via interfaces + case IButton iBtn: + iBtn.Clicked(); + return "ok"; + case ISwitch iSw: + iSw.IsOn = !iSw.IsOn; + return "ok"; + case ICheckBox iCb: + iCb.IsChecked = !iCb.IsChecked; + return "ok"; + case IRadioButton iRb: + iRb.IsChecked = true; + return "ok"; + case IView iView when iView.Handler?.PlatformView != null: + // Last resort: try native tap via handler's platform view + if (TryNativeTapOnHandler(iView)) + return "ok"; + return $"Unhandled IView type: {el.GetType().FullName}"; default: return $"Unhandled type: {el.GetType().FullName}"; } @@ -1244,6 +1263,52 @@ protected virtual bool TryNativeTap(VisualElement ve) return false; } + /// + /// Attempts to tap a native platform view via handler for non-VisualElement IView types (e.g. Comet views). + /// Uses reflection to get the PlatformView from the handler and invoke SendAccessibilityAction or performClick. + /// Override in platform-specific subclasses for richer support. + /// + protected virtual bool TryNativeTapOnHandler(IView view) + { + try + { + var handler = view.Handler; + if (handler == null) return false; + + // Use safe reflection to get PlatformView (avoids AmbiguousMatchException on generic handlers) + var platformViewProp = CometViewResolver.GetPropertySafe(handler.GetType(), "PlatformView"); + if (platformViewProp == null) return false; + + var platformView = platformViewProp.GetValue(handler); + if (platformView == null) return false; + + // Try to invoke SendActionForControlEvents on UIControl (iOS/macCatalyst) + var sendActionMethod = platformView.GetType().GetMethod("SendActionForControlEvents", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + if (sendActionMethod != null) + { + // UIControlEvent.TouchUpInside = 1 << 6 = 64 + sendActionMethod.Invoke(platformView, new object[] { (nuint)64 }); + return true; + } + + // Try performClick for Android + var performClickMethod = platformView.GetType().GetMethod("PerformClick", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, + null, Type.EmptyTypes, null); + if (performClickMethod != null) + { + performClickMethod.Invoke(platformView, null); + return true; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[MauiDevFlow] TryNativeTapOnHandler failed: {ex.GetBaseException().Message}"); + } + return false; + } + private async Task HandleFill(HttpRequest request) { if (_app == null) return HttpResponse.Error("Agent not bound to app"); diff --git a/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs b/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs index edf0316..5cce6d1 100644 --- a/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs +++ b/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs @@ -1192,12 +1192,15 @@ private ElementInfo CreateToolbarItemInfo(ToolbarItem item, string parentId) private ElementInfo CreateElementInfo(IVisualTreeElement element, string id, string? parentId) { + // Try to resolve Comet view type first (if Comet is loaded) + var cometResolved = CometViewResolver.TryResolveCometView(element); + var info = new ElementInfo { Id = id, ParentId = parentId, - Type = element.GetType().Name, - FullType = element.GetType().FullName ?? element.GetType().Name, + Type = cometResolved?.Type ?? element.GetType().Name, + FullType = cometResolved?.FullType ?? element.GetType().FullName ?? element.GetType().Name, }; if (element is VisualElement ve) @@ -1225,9 +1228,33 @@ private ElementInfo CreateElementInfo(IVisualTreeElement element, string id, str // Resolve window-absolute bounds via platform-native APIs info.WindowBounds = ResolveWindowBounds(ve); } + // Comet views implement IView but NOT VisualElement — extract info from IView + handler + else if (element is IView iView) + { + info.IsVisible = iView.Visibility != Visibility.Collapsed; + info.IsEnabled = iView.IsEnabled; + info.Opacity = double.IsFinite(iView.Opacity) ? iView.Opacity : 1; + + // Try to get bounds from the IView's Frame + var frame = iView.Frame; + if (frame.Width > 0 || frame.Height > 0) + { + info.Bounds = new BoundsInfo + { + X = double.IsFinite(frame.X) ? frame.X : 0, + Y = double.IsFinite(frame.Y) ? frame.Y : 0, + Width = double.IsFinite(frame.Width) ? frame.Width : 0, + Height = double.IsFinite(frame.Height) ? frame.Height : 0 + }; + } + + // Try to extract text from Comet views via IText interface + if (element is IText iText && !string.IsNullOrEmpty(iText.Text)) + info.Text = iText.Text; + } // Extract text from common controls (including Shell elements) - info.Text = element switch + info.Text ??= element switch { Label l => l.Text, Button b => b.Text, @@ -1236,6 +1263,8 @@ private ElementInfo CreateElementInfo(IVisualTreeElement element, string id, str SearchBar sb => sb.Text, Span s => s.Text, BaseShellItem si => si.Title, + // Fallback to IText interface for non-Controls types (e.g. Comet views) + IText it when info.Text == null => it.Text, _ => null }; @@ -1297,6 +1326,23 @@ private ElementInfo CreateElementInfo(IVisualTreeElement element, string id, str catch { /* ItemsSource may not support counting */ } } + // Add Comet-specific properties if this is a Comet view + if (cometResolved != null) + { + info.NativeProperties ??= new Dictionary(); + foreach (var kvp in cometResolved.Value.Properties) + { + info.NativeProperties[kvp.Key] = kvp.Value; + } + + // Optionally add environment values (can be verbose, so commented out by default) + // var envValues = CometViewResolver.GetEnvironmentValues(cometResolved.Value.CometView); + // foreach (var kvp in envValues) + // { + // info.NativeProperties[$"Env_{kvp.Key}"] = kvp.Value; + // } + } + return info; } From f4b2f4a82980271bfecab755f00ef1c9373e2da8 Mon Sep 17 00:00:00 2001 From: David Ortinau Date: Wed, 11 Mar 2026 11:47:55 -0500 Subject: [PATCH 2/2] Add Comet gesture tap, IScrollView scroll, and stable ID support Three changes to support Comet views in MauiDevFlow automation: 1. HandleTap: Add IGestureView support via reflection. Comet views use IGestureView.Gestures with TapGesture.Invoke() instead of MAUI TapGestureRecognizer. New TryInvokeCometGestureTap checks for the interface by name and invokes tap gestures without a Comet dependency. 2. HandleScroll: Add IView/IScrollView support. Comet ScrollView implements IScrollView but not Controls.ScrollView. Added TryNativeScrollOnHandler for element-targeted scroll, and FindDescendantIScrollView for page-level scroll. Platform override TryNativeScrollOnPlatformView delegates to native UIScrollView (iOS/macCatalyst), RecyclerView (Android), or ScrollViewer (Windows). 3. GenerateId: Extract platform view from IView.Handler for EnsurePlatformStableId. Comet auto-stamps AccessibilityIdentifier on native views, but GenerateId was passing the Comet.View to EnsurePlatformStableId which expected a UIView. Now falls through to handler.PlatformView, yielding stable platform-stamped IDs. Validated against CometControlsGallery on Mac Catalyst: - Sidebar tap (Text + OnTap gesture): WORKS - Button tap (IButton interface): WORKS - ScrollView scroll (element-targeted): WORKS - Page-level scroll (IScrollView discovery): WORKS - MAUI reference app: No regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DevFlowAgentService.cs | 127 ++++++++++++++++++ .../VisualTreeWalker.cs | 30 ++++- src/MauiDevFlow.Agent/DevFlowAgentService.cs | 62 +++++++++ 3 files changed, 216 insertions(+), 3 deletions(-) diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 95e19fc..45dcfeb 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -1189,6 +1189,10 @@ private async Task HandleTap(HttpRequest request) return "ok"; return $"No tap handler on {el.GetType().FullName} (gestures:{v.GestureRecognizers.Count}, type:{v.GetType().Name})"; + // Comet views implement IGestureView with Gesture objects that have Invoke(). + // Check via reflection to avoid a hard Comet dependency. + case IView gestureView when TryInvokeCometGestureTap(gestureView): + return "ok"; // Comet views implement MAUI interfaces (IButton, ISwitch, etc.) // but not Microsoft.Maui.Controls classes, so handle via interfaces case IButton iBtn: @@ -1254,6 +1258,52 @@ private static bool TryInvokeTapped(TapGestureRecognizer tapGesture, View sender return false; } + /// + /// Attempts to invoke a Comet-style tap gesture on an IView via reflection. + /// Checks for IGestureView interface by name, iterates Gestures looking for TapGesture, + /// and calls Invoke(). No hard Comet dependency required. + /// + private static bool TryInvokeCometGestureTap(IView view) + { + try + { + // Check if the view implements an interface named "IGestureView" with a "Gestures" property + var gestureViewInterface = view.GetType().GetInterfaces() + .FirstOrDefault(i => i.Name == "IGestureView"); + if (gestureViewInterface == null) return false; + + var gesturesProp = gestureViewInterface.GetProperty("Gestures"); + if (gesturesProp == null) return false; + + var gestures = gesturesProp.GetValue(view) as System.Collections.IEnumerable; + if (gestures == null) return false; + + // Find the first gesture whose type name contains "TapGesture" + foreach (var gesture in gestures) + { + if (gesture == null) continue; + var gestureType = gesture.GetType(); + if (gestureType.Name.Contains("TapGesture") || + (gestureType.BaseType != null && gestureType.BaseType.Name.Contains("TapGesture"))) + { + // Call Invoke() — public virtual method on Comet.Gesture + var invokeMethod = gestureType.GetMethod("Invoke", + BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); + if (invokeMethod != null) + { + invokeMethod.Invoke(gesture, null); + return true; + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[MauiDevFlow] TryInvokeCometGestureTap failed: {ex.GetBaseException().Message}"); + } + return false; + } + /// /// Attempts to tap a native platform view as a fallback. /// Override in platform-specific subclasses for native tap support. @@ -1630,6 +1680,14 @@ await ScrollWithTimeoutAsync( return "ok"; } } + // Comet views implement IView/IScrollView but NOT VisualElement. + // Try native scroll via the handler's platform view. + else if (el is IView iView && (body.DeltaX != 0 || body.DeltaY != 0)) + { + if (await TryNativeScrollOnHandler(iView, body.DeltaX, body.DeltaY)) + return "ok"; + return $"Native scroll not supported for IView type: {el.GetType().FullName}"; + } return $"No scrollable ancestor found for element '{body.ElementId}'"; } @@ -1663,6 +1721,12 @@ await ScrollWithTimeoutAsync( return "ok"; } + // 3c: Try IView-based scroll (Comet ScrollView implements IScrollView, not Controls.ScrollView) + // Walk the visual tree looking for IScrollView implementations via IVisualTreeElement + var iScrollView = FindDescendantIScrollView(currentPage); + if (iScrollView != null && await TryNativeScrollOnHandler(iScrollView, body.DeltaX, body.DeltaY)) + return "ok"; + return "No scrollable view found on page"; }); @@ -1736,6 +1800,69 @@ protected virtual Task TryNativeScroll(VisualElement element, double delta return Task.FromResult(false); } + /// + /// Attempts native scroll on an IView (e.g. Comet ScrollView) via its handler's platform view. + /// Uses reflection to find UIScrollView (iOS/macCatalyst), Android ScrollView, or WinUI ScrollViewer. + /// Override in platform-specific subclasses for richer support. + /// + protected virtual Task TryNativeScrollOnHandler(IView view, double deltaX, double deltaY) + { + try + { + var handler = view.Handler; + if (handler == null) return Task.FromResult(false); + + var platformViewProp = CometViewResolver.GetPropertySafe(handler.GetType(), "PlatformView"); + if (platformViewProp == null) return Task.FromResult(false); + + var platformView = platformViewProp.GetValue(handler); + if (platformView == null) return Task.FromResult(false); + + // Delegate to platform override's native scroll capability via reflection + // Look for UIScrollView (iOS/macCatalyst) via searching the native view hierarchy + var scrollResult = TryNativeScrollOnPlatformView(platformView, deltaX, deltaY); + return Task.FromResult(scrollResult); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[MauiDevFlow] TryNativeScrollOnHandler failed: {ex.GetBaseException().Message}"); + } + return Task.FromResult(false); + } + + /// + /// Attempts native scroll directly on a platform view object. + /// Override in platform-specific subclasses (iOS, Android, Windows) for real implementations. + /// + protected virtual bool TryNativeScrollOnPlatformView(object platformView, double deltaX, double deltaY) + { + return false; + } + + /// + /// Walks the visual tree from a root element looking for an IScrollView implementation + /// (including Comet ScrollView which implements IScrollView but not Controls.ScrollView). + /// Accepts IVisualTreeElement to traverse Comet views that are not Element subclasses. + /// + private static IView? FindDescendantIScrollView(IVisualTreeElement root) + { + if (root is IScrollView && root is IView svView) + return svView; + + foreach (var child in root.GetVisualChildren()) + { + if (child is IScrollView && child is IView childView) + return childView; + if (child is IVisualTreeElement childVte) + { + var found = FindDescendantIScrollView(childVte); + if (found != null) return found; + } + } + + return null; + } + /// /// Animated ScrollToAsync can deadlock on iOS when dispatched. /// Fall back to non-animated scroll if the animated version doesn't complete in time. diff --git a/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs b/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs index 5cce6d1..1ec4b7b 100644 --- a/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs +++ b/src/MauiDevFlow.Agent.Core/VisualTreeWalker.cs @@ -494,9 +494,33 @@ private string GenerateId(IVisualTreeElement element) } else { - // Non-Element IVisualTreeElement (rare) - var platformId = EnsurePlatformStableId(element); - id = platformId ?? RuntimeHelpers.GetHashCode(element).ToString("x8"); + // Non-Element IVisualTreeElement — check IView.AutomationId first + // (Comet views auto-stamp AutomationId via AppHostBuilderExtensions) + string? automId = null; + if (element is IView iv && !string.IsNullOrEmpty(iv.AutomationId)) + automId = iv.AutomationId; + + if (automId != null) + { + id = automId; + if (_usedIds.Contains(id)) + id = $"{id}_{RuntimeHelpers.GetHashCode(element):x8}"; + } + else + { + // Try the element itself first, then try the handler's PlatformView + // (Comet views stamp AccessibilityIdentifier on the native view, not IView.AutomationId) + var platformId = EnsurePlatformStableId(element); + if (platformId == null && element is IView iViewForPlatform) + { + var platformView = CometViewResolver.GetPropertySafe( + iViewForPlatform.Handler?.GetType() ?? typeof(object), "PlatformView") + ?.GetValue(iViewForPlatform.Handler); + if (platformView != null) + platformId = EnsurePlatformStableId(platformView); + } + id = platformId ?? RuntimeHelpers.GetHashCode(element).ToString("x8"); + } } _usedIds.Add(id); diff --git a/src/MauiDevFlow.Agent/DevFlowAgentService.cs b/src/MauiDevFlow.Agent/DevFlowAgentService.cs index 93e2014..8782ba0 100644 --- a/src/MauiDevFlow.Agent/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent/DevFlowAgentService.cs @@ -130,6 +130,68 @@ protected override Task TryNativeScroll(VisualElement element, double delt return Task.FromResult(false); } + protected override bool TryNativeScrollOnPlatformView(object platformView, double deltaX, double deltaY) + { + try + { +#if IOS || MACCATALYST + var uiView = platformView as UIKit.UIView; + UIKit.UIScrollView? uiScrollView = uiView as UIKit.UIScrollView; + if (uiScrollView == null) + uiScrollView = FindNativeDescendant(uiView); + if (uiScrollView == null) + uiScrollView = FindNativeAncestor(uiView); + if (uiScrollView != null) + { + var offset = uiScrollView.ContentOffset; + var newX = Math.Max(0, Math.Min(offset.X + deltaX, uiScrollView.ContentSize.Width - uiScrollView.Bounds.Width)); + var newY = Math.Max(0, Math.Min(offset.Y - deltaY, uiScrollView.ContentSize.Height - uiScrollView.Bounds.Height)); + uiScrollView.SetContentOffset(new CoreGraphics.CGPoint(newX, newY), animated: true); + return true; + } +#elif ANDROID + var androidView = platformView as Android.Views.View; + var recyclerView = androidView as AndroidX.RecyclerView.Widget.RecyclerView; + if (recyclerView == null) + recyclerView = FindNativeDescendantAndroid(androidView); + if (recyclerView == null) + recyclerView = FindNativeAncestorAndroid(androidView); + if (recyclerView != null) + { + recyclerView.ScrollBy((int)deltaX, (int)-deltaY); + return true; + } + var androidScrollView = androidView as Android.Widget.ScrollView; + if (androidScrollView == null) + androidScrollView = FindNativeDescendantAndroid(androidView); + if (androidScrollView == null) + androidScrollView = FindNativeAncestorAndroid(androidView); + if (androidScrollView != null) + { + androidScrollView.ScrollBy((int)deltaX, (int)-deltaY); + return true; + } +#elif WINDOWS + var winView = platformView as Microsoft.UI.Xaml.DependencyObject; + var scrollViewer = winView as Microsoft.UI.Xaml.Controls.ScrollViewer; + if (scrollViewer == null) + scrollViewer = FindWinUIDescendant(winView); + if (scrollViewer == null) + scrollViewer = FindWinUIScrollViewer(winView); + if (scrollViewer != null) + { + scrollViewer.ChangeView( + scrollViewer.HorizontalOffset + deltaX, + scrollViewer.VerticalOffset - deltaY, + null); + return true; + } +#endif + } + catch { } + return false; + } + #if IOS || MACCATALYST private static T? FindNativeAncestor(UIKit.UIView? view) where T : UIKit.UIView {