From 5b91187bb4001b4fbae1044b6198a32a27e77eb0 Mon Sep 17 00:00:00 2001 From: Norton Andreev Date: Mon, 19 Oct 2026 16:19:34 +0300 Subject: [PATCH 1/2] [AI-3828] app: Show the text cursor over chat prose The viewer cursor is the hand on a link and the text cursor on the rest of the message. --- src/Capacitor.App/Views/MarkdownView.cs | 9 +++-- .../ChatCursorTests.cs | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 test/Capacitor.App.Tests.Unit/ChatCursorTests.cs diff --git a/src/Capacitor.App/Views/MarkdownView.cs b/src/Capacitor.App/Views/MarkdownView.cs index 384071efba..70e11787fd 100644 --- a/src/Capacitor.App/Views/MarkdownView.cs +++ b/src/Capacitor.App/Views/MarkdownView.cs @@ -40,6 +40,8 @@ public sealed class MarkdownView : ContentControl { // The extension builds its TextMate highlighters on first use and keeps them, so one // instance serves the app; a per-view instance rebuilds them on every render. static readonly TextMateExtension Highlighting = new(); + static readonly Cursor IBeamCursor = new(StandardCursorType.Ibeam); + static readonly Cursor HandCursor = new(StandardCursorType.Hand); readonly MarkdownViewer _viewer = new(); readonly DetailsState _details = new(); @@ -72,6 +74,8 @@ public MarkdownView() { if (OpenLink is { } open && open.CanExecute(e.Url)) open.Execute(e.Url); }; MarkViewSelection.Attach(this, _viewer, CopySelectionAsync); + // Bubble on the host runs after the viewer's own move, which has already chosen Hand or + // the arrow. Selectable prose keeps the I-beam; a link keeps the hand. AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Bubble, handledEventsToo: true); ContextFlyout = TextContextMenu.ForMarkdown; Content = _viewer; @@ -84,10 +88,11 @@ internal void ExtendSelection(Point pointInView) { } void OnPointerMoved(object? sender, PointerEventArgs e) { - var tip = FindHyperlinkAt(_viewer, e.GetPosition(_viewer))?.NavigateUri?.ToString() - ?? ImageUnder(e.Source as Visual)?.Target; + var link = FindHyperlinkAt(_viewer, e.GetPosition(_viewer)); + var tip = link?.NavigateUri?.ToString() ?? ImageUnder(e.Source as Visual)?.Target; if (!string.Equals(ToolTip.GetTip(_viewer) as string, tip, StringComparison.Ordinal)) ToolTip.SetTip(_viewer, tip); + _viewer.Cursor = link is null ? IBeamCursor : HandCursor; } static MarkdownImage? ImageUnder(Visual? visual) { diff --git a/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs b/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs new file mode 100644 index 0000000000..300fbea886 --- /dev/null +++ b/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs @@ -0,0 +1,33 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Threading; +using static Capacitor.App.Tests.Unit.AvaloniaSession; +using static Capacitor.App.Tests.Unit.MarkdownViewHarness; + +namespace Capacitor.App.Tests.Unit; + +public class ChatCursorTests { + [Test] + [NotInParallel("AvaloniaSession")] + public async Task Prose_uses_the_text_cursor_and_a_link_uses_the_hand() { + await RunOnUiAsync(async () => { + var (window, root, _) = Show("See [docs](https://example.com/docs) now."); + try { + var paragraph = Paragraphs(root).Single(); + var viewer = Viewer(root); + Move(window, paragraph, 0); + await Assert.That(viewer.Cursor?.ToString()).IsEqualTo("Ibeam"); + Move(window, paragraph, StartOf(paragraph, Links(root).Single())); + await Assert.That(viewer.Cursor?.ToString()).IsEqualTo("Hand"); + } finally { window.Close(); } + }); + } + + static void Move(Window window, TextBlock block, int index) { + var glyph = block.TextLayout.HitTestTextPosition(index); + var point = block.TranslatePoint(new Point(glyph.X + 2, glyph.Y + glyph.Height / 2), window)!.Value; + window.MouseMove(point); + Dispatcher.UIThread.RunJobs(); + } +} From b528dd935dc0cad18bd86a6dd21c1b48eba9a3c0 Mon Sep 17 00:00:00 2001 From: Norton Andreev Date: Wed, 21 Oct 2026 14:06:02 +0300 Subject: [PATCH 2/2] [AI-3828] app: Keep the arrow over a bare markdown image Cursor inherits, so the viewer's I-beam reached a bare image that never set one. --- src/Capacitor.App/Views/MarkdownImage.cs | 3 +- src/Capacitor.App/Views/MarkdownView.cs | 11 +++++-- .../ChatCursorTests.cs | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.App/Views/MarkdownImage.cs b/src/Capacitor.App/Views/MarkdownImage.cs index 3235398baf..c13b7caa4c 100644 --- a/src/Capacitor.App/Views/MarkdownImage.cs +++ b/src/Capacitor.App/Views/MarkdownImage.cs @@ -51,7 +51,8 @@ public MarkdownImage(string url, string label, ImageSize? size, string? target, Children.Add(_picture); // A transparent fill makes the whole box hit-testable, not just the glyphs or pixels. Background = Brushes.Transparent; - if (target is not null) Cursor = new Cursor(StandardCursorType.Hand); + // Cursor inherits, so an unset value would take the viewer's I-beam. A bare image is not text. + Cursor = new Cursor(target is not null ? StandardCursorType.Hand : StandardCursorType.Arrow); } public string Url { get; } diff --git a/src/Capacitor.App/Views/MarkdownView.cs b/src/Capacitor.App/Views/MarkdownView.cs index 70e11787fd..08f1863262 100644 --- a/src/Capacitor.App/Views/MarkdownView.cs +++ b/src/Capacitor.App/Views/MarkdownView.cs @@ -42,6 +42,7 @@ public sealed class MarkdownView : ContentControl { static readonly TextMateExtension Highlighting = new(); static readonly Cursor IBeamCursor = new(StandardCursorType.Ibeam); static readonly Cursor HandCursor = new(StandardCursorType.Hand); + static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow); readonly MarkdownViewer _viewer = new(); readonly DetailsState _details = new(); @@ -75,7 +76,7 @@ public MarkdownView() { }; MarkViewSelection.Attach(this, _viewer, CopySelectionAsync); // Bubble on the host runs after the viewer's own move, which has already chosen Hand or - // the arrow. Selectable prose keeps the I-beam; a link keeps the hand. + // the arrow. Prose takes the I-beam and a link the hand; a bare image stays on the arrow. AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Bubble, handledEventsToo: true); ContextFlyout = TextContextMenu.ForMarkdown; Content = _viewer; @@ -89,10 +90,14 @@ internal void ExtendSelection(Point pointInView) { void OnPointerMoved(object? sender, PointerEventArgs e) { var link = FindHyperlinkAt(_viewer, e.GetPosition(_viewer)); - var tip = link?.NavigateUri?.ToString() ?? ImageUnder(e.Source as Visual)?.Target; + var image = ImageUnder(e.Source as Visual); + var tip = link?.NavigateUri?.ToString() ?? image?.Target; if (!string.Equals(ToolTip.GetTip(_viewer) as string, tip, StringComparison.Ordinal)) ToolTip.SetTip(_viewer, tip); - _viewer.Cursor = link is null ? IBeamCursor : HandCursor; + // Cursor inherits, so the viewer's value is what a child with none of its own shows. + _viewer.Cursor = link is not null || image?.Target is not null ? HandCursor + : image is not null ? ArrowCursor + : IBeamCursor; } static MarkdownImage? ImageUnder(Visual? visual) { diff --git a/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs b/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs index 300fbea886..1218b50e4a 100644 --- a/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs +++ b/test/Capacitor.App.Tests.Unit/ChatCursorTests.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Threading; +using Capacitor.App.Views; using static Capacitor.App.Tests.Unit.AvaloniaSession; using static Capacitor.App.Tests.Unit.MarkdownViewHarness; @@ -24,6 +25,35 @@ await RunOnUiAsync(async () => { }); } + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_bare_image_keeps_the_arrow_and_a_linked_image_keeps_the_hand() { + await RunOnUiAsync(async () => { + var (window, root, _) = Show( + "See ![shot](https://h/x.png) here.\n\n[![badge](https://h/b.png)](https://outer.example)", + MarkdownFlavor.GitHub); + try { + var bare = Images(root).Single(i => i.Target is null); + var linked = Images(root).Single(i => i.Target is not null); + var viewer = Viewer(root); + MoveOver(window, bare); + await Assert.That(bare.Cursor?.ToString()).IsEqualTo("Arrow"); + await Assert.That(viewer.Cursor?.ToString()).IsEqualTo("Arrow"); + MoveOver(window, linked); + await Assert.That(linked.Cursor?.ToString()).IsEqualTo("Hand"); + await Assert.That(viewer.Cursor?.ToString()).IsEqualTo("Hand"); + Move(window, Paragraphs(root).Single(p => Reads(p).Contains("See")), 0); + await Assert.That(viewer.Cursor?.ToString()).IsEqualTo("Ibeam"); + } finally { window.Close(); } + }); + } + + static void MoveOver(Window window, Control control) { + var point = control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value; + window.MouseMove(point); + Dispatcher.UIThread.RunJobs(); + } + static void Move(Window window, TextBlock block, int index) { var glyph = block.TextLayout.HitTestTextPosition(index); var point = block.TranslatePoint(new Point(glyph.X + 2, glyph.Y + glyph.Height / 2), window)!.Value;