Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Capacitor.App/Views/MarkdownImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
14 changes: 12 additions & 2 deletions src/Capacitor.App/Views/MarkdownView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ 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);
static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow);

readonly MarkdownViewer _viewer = new();
readonly DetailsState _details = new();
Expand Down Expand Up @@ -72,6 +75,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. 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;
Expand All @@ -84,10 +89,15 @@ 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 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);
// 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) {
Expand Down
63 changes: 63 additions & 0 deletions test/Capacitor.App.Tests.Unit/ChatCursorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Avalonia;
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;

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(); }
});
}

[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;
window.MouseMove(point);
Dispatcher.UIThread.RunJobs();
}
}
Loading