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
35 changes: 35 additions & 0 deletions src/Capacitor.App/GitHubHtml/ChatPipeline.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Markdig;
using Markdig.Renderers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using MarkView.Avalonia;

namespace Capacitor.App.GitHubHtml;

/// Chat markdown is MarkView's supported set, with links hoisted out of emphasis. A bare builder
/// is CommonMark and drops the tables and autolinks chat already renders. MarkView only activates
/// a hyperlink that is a direct inline of its text block.
public static class ChatPipeline {
public static MarkdownPipeline Instance { get; } =
new MarkdownPipelineBuilder().UseSupportedExtensions().Use(new Hoist()).Build();

sealed class Hoist : IMarkdownExtension {
public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Process;

public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }

static void Process(MarkdownDocument document) {
foreach (var leaf in document.Descendants<LeafBlock>())
if (leaf.Inline is { } root) {
foreach (var auto in root.Descendants<AutolinkInline>().ToArray()) {
var link = new LinkInline(auto.IsEmail ? "mailto:" + auto.Url : auto.Url, "") {
IsAutoLink = true, IsClosed = true,
};
link.AppendChild(new LiteralInline(auto.Url));
auto.ReplaceBy(link, copyChildren: false);
}
LinkHoister.Hoist(root, includeImages: false);
}
}
}
}
10 changes: 5 additions & 5 deletions src/Capacitor.App/GitHubHtml/LinkHoister.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ namespace Capacitor.App.GitHubHtml;
/// Lifts links above their emphasis ancestors. MarkView activates only a hyperlink that is a
/// direct inline of its text block, and the emphasis renders the same from inside the link.
static class LinkHoister {
public static void Hoist(ContainerInline root) {
public static void Hoist(ContainerInline root, bool includeImages = true) {
var links = new List<LinkInline>();
Collect(root, links);
Collect(root, links, includeImages);
// Last link first: what follows a link inside its emphasis moves to a trailing copy, and
// going backwards leaves each inline to be moved once rather than once per earlier link.
for (var i = links.Count - 1; i >= 0; i--) HoistOne(links[i]);
}

static void Collect(ContainerInline container, List<LinkInline> links) {
static void Collect(ContainerInline container, List<LinkInline> links, bool includeImages) {
for (var child = container.FirstChild; child is not null; child = child.NextSibling) {
if (child is LinkInline link) links.Add(link);
if (child is ContainerInline nested) Collect(nested, links);
if (child is LinkInline link && (includeImages || !link.IsImage)) links.Add(link);
if (child is ContainerInline nested) Collect(nested, links, includeImages);
}
}

Expand Down
55 changes: 53 additions & 2 deletions src/Capacitor.App/Views/ChatTabView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public partial class ChatTabView : UserControl {
bool _edgeScrollQueued;
Point _dragInScroll;
MarkdownView? _dragView;
/// A press is in flight. Follow-tail must not move the transcript under that pointer, or a
/// link click is measured as a drag and dropped.
IPointer? _primaryPointer;
TopLevel? _pointerRoot;
bool _tailDeferred;
bool PrimaryDown => _primaryPointer is not null;
double? _thumbFraction;
double _smoothRemaining;
bool _smoothFrameQueued;
Expand Down Expand Up @@ -88,6 +94,10 @@ public ChatTabView() {
/// the same view, and a behaviour disposed on the way out has to come back with it.
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) {
base.OnAttachedToVisualTree(e);
_pointerRoot = TopLevel.GetTopLevel(this);
_pointerRoot?.AddHandler(PointerReleasedEvent, OnPrimaryReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
_pointerRoot?.AddHandler(PointerCaptureLostEvent, OnPrimaryCaptureLost, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
if (_pointerRoot is Window window) window.Deactivated += OnWindowDeactivated;
_attachments ??= AttachmentDropPaste.Attach(
ComposerCard, ComposerInput, AttachButton,
() => (DataContext as ChatTabViewModel)?.Attachments, TimeProvider.System);
Expand All @@ -103,6 +113,8 @@ protected override void OnDataContextChanged(EventArgs e) {
_readerGesture = false;
_dragSelecting = false;
_dragView = null;
_primaryPointer = null;
_tailDeferred = false;
_followTail = true;
base.OnDataContextChanged(e);
}
Expand All @@ -114,6 +126,12 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
_thumbFraction = null;
_dragSelecting = false;
_dragView = null;
_primaryPointer = null;
_tailDeferred = false;
_pointerRoot?.RemoveHandler(PointerReleasedEvent, OnPrimaryReleased);
_pointerRoot?.RemoveHandler(PointerCaptureLostEvent, OnPrimaryCaptureLost);
if (_pointerRoot is Window window) window.Deactivated -= OnWindowDeactivated;
_pointerRoot = null;
_attachments?.Dispose();
_attachments = null;
base.OnDetachedFromVisualTree(e);
Expand All @@ -124,6 +142,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
internal bool CanPreserveReadingPosition => !_followTail && _anchorItem is null && _thumbFraction is null;

void OnReaderGesture(object? sender, RoutedEventArgs e) {
if (e is PointerPressedEventArgs pressed && _primaryPointer is null
&& pressed.GetCurrentPoint(ChatItems).Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonPressed)
_primaryPointer = pressed.Pointer;
_listInputVersion++;
// A TextBox in the list (Other…, free-text) is editing, not reading: arming follow-tail
// would ScrollToEnd and recycle its virtualizing row, which drops the caret.
Expand All @@ -144,6 +165,31 @@ void OnReaderGesture(object? sender, RoutedEventArgs e) {
Dispatcher.UIThread.Post(() => _readerGesture = false, DispatcherPriority.Background);
}

void OnPrimaryReleased(object? sender, PointerReleasedEventArgs e) {
if (ReferenceEquals(e.Pointer, _primaryPointer) && e.InitialPressMouseButton == MouseButton.Left)
CompletePrimaryPress();
}

void OnPrimaryCaptureLost(object? sender, PointerCaptureLostEventArgs e) {
if (ReferenceEquals(e.Pointer, _primaryPointer)) CompletePrimaryPress();
}

void OnWindowDeactivated(object? sender, EventArgs e) => CompletePrimaryPress();

void CompletePrimaryPress() {
_primaryPointer = null;
_dragSelecting = false;
if (!_tailDeferred) return;
_tailDeferred = false;
var context = DataContext;
Dispatcher.UIThread.Post(() => {
if (PrimaryDown || !_followTail || _dragSelecting || _anchorItem is not null
|| _thumbFraction is not null || ListTextBoxOwnsFocus()
|| !ReferenceEquals(context, DataContext) || _pointerRoot is null) return;
_scroll?.ScrollToEnd();
}, DispatcherPriority.Background);
}

void OnChatWheelChanged(object? sender, PointerWheelEventArgs e) {
if (e.Handled || e.KeyModifiers != KeyModifiers.None || OriginatesFromTextBox(e)) return;
EnsureChatScroll();
Expand Down Expand Up @@ -396,8 +442,13 @@ void OnScrollChanged(object? sender, ScrollChangedEventArgs e) {
// same layout pass. That still counts as the reader returning to the bottom.
var reachedEstimatedEnd = e.OffsetDelta.Y > 0 && e.ExtentDelta.Y > 0
&& ChatItems.ItemsPanelRoot is ChatVirtualizingPanel { MeasuredAtEstimatedEnd: true };
_followTail = _readerGesture ? atBottom || reachedEstimatedEnd : _followTail || atBottom || reachedEstimatedEnd;
if (_followTail && !atBottom) scroll.ScrollToEnd();
var extentOnlyWhilePressed = PrimaryDown && _followTail
&& e.ExtentDelta.Y > 0 && Math.Abs(e.OffsetDelta.Y) <= 0.5;
_followTail = extentOnlyWhilePressed || (_readerGesture ? atBottom || reachedEstimatedEnd : _followTail || atBottom || reachedEstimatedEnd);
if (_followTail && !atBottom) {
if (PrimaryDown) _tailDeferred = true;
else scroll.ScrollToEnd();
}
}

void OnSelectionDrag(object? sender, PointerEventArgs e) {
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/Views/MarkdownView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ void ApplyFlavor() {
// After the highlighter, whose code block renderer this one wraps.
_viewer.Extensions.Add(_codeActions);
// Only the pipeline change re-renders, so it goes last.
_viewer.Pipeline = Flavor == MarkdownFlavor.GitHub ? GitHubPipeline.Instance : null;
_viewer.Pipeline = Flavor == MarkdownFlavor.GitHub ? GitHubPipeline.Instance : ChatPipeline.Instance;
}

/// Re-rendering rebuilds MarkView's selection index, so it always matches what is visible.
Expand Down
37 changes: 37 additions & 0 deletions test/Capacitor.App.Tests.Unit/ChatLinkHoistTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Avalonia.Controls.Documents;
using Avalonia.Media;
using MarkView.Avalonia.Rendering.Inlines;
using static Capacitor.App.Tests.Unit.AvaloniaSession;
using static Capacitor.App.Tests.Unit.MarkdownViewHarness;

namespace Capacitor.App.Tests.Unit;

public class ChatLinkHoistTests {
[Test]
[Arguments("See **[docs](https://example.com/docs)** now.")]
[Arguments("See **<https://example.com/docs>** now.")]
Comment thread
nortonandreev marked this conversation as resolved.
[Arguments("See **https://example.com/docs** now.")]
[NotInParallel("AvaloniaSession")]
public async Task A_bold_markdown_link_is_clickable(string markdown) {
await RunOnUiAsync(async () => {
var (window, root, opened) = Show(markdown);
try {
var paragraph = Paragraphs(root).Single();
var link = paragraph.Inlines!.OfType<MarkdownHyperlink>().Single();
await Assert.That(link.NavigateUri?.ToString()).IsEqualTo("https://example.com/docs");
Click(window, paragraph, link);
await Assert.That(opened).IsEquivalentTo(new[] { "https://example.com/docs" });
} finally { window.Close(); }
});
}
[Test]
[NotInParallel("AvaloniaSession")]
public Task An_emphasized_image_placeholder_keeps_its_style() => RunOnUiAsync(async () => {
var (window, root, _) = Show("**![badge](https://example.com/image.png)**");
try {
var bold = Paragraphs(root).Single().Inlines!.OfType<Bold>().Single();
await Assert.That(bold.Inlines.Text).IsEqualTo("![badge](https://example.com/image.png)");
await Assert.That(bold.FontWeight).IsEqualTo(FontWeight.Bold);
} finally { window.Close(); }
});
}
51 changes: 47 additions & 4 deletions test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -574,23 +574,66 @@ await RunOnUiAsync(async () => {
public async Task A_link_in_an_assistant_row_opens_through_the_tabs_command() {
await RunOnUiAsync(async () => {
var host = new Host();
await host.LoadAsync(Tmp.CreateFile("link.jsonl", [AssistantLinkLine]));
var path = Tmp.CreateFile("link.jsonl", Enumerable.Repeat(UserLine, 40).Append(AssistantLinkLine).ToArray());
await host.LoadAsync(path);

await Assert.That(host.Chat.Items).Count().IsEqualTo(1);
await Assert.That(host.Chat.Items).Count().IsEqualTo(41);

var paragraph = host.View.GetVisualDescendants().OfType<TextBlock>().Single(t => t.Classes.Contains("markdown-paragraph"));
var paragraph = host.View.GetVisualDescendants().OfType<TextBlock>().Single(t => t.Classes.Contains("markdown-paragraph") && MarkdownViewHarness.Reads(t).Contains("docs", StringComparison.Ordinal));
var glyph = paragraph.TextLayout.HitTestTextPosition("See ".Length);
var origin = paragraph.TranslatePoint(new Point(glyph.X + paragraph.Padding.Left + 2, glyph.Y + paragraph.Padding.Top + glyph.Height / 2), host.Window)!.Value;
host.Window.MouseMove(origin);
host.Window.MouseDown(origin, MouseButton.Left);
await host.AppendAndTickAsync(path, 4);
host.Settle();
await Assert.That(host.AtBottom()).IsFalse();
host.Window.MouseUp(origin, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
host.Settle();

await Assert.That(host.AtBottom()).IsTrue();
await Assert.That(host.Opener.Opened).IsEquivalentTo(new[] { "https://example.com/docs" });
await host.CloseAsync();
});
}

[Test]
[Arguments(false, false)]
[Arguments(true, false)]
[Arguments(false, true)]
[NotInParallel("AvaloniaSession")]
public Task Follow_tail_resumes_after_a_primary_press(bool releaseOutside, bool loseCapture) => RunOnUiAsync(async () => {
var host = new Host();
try {
var path = Tmp.CreateFile("press-tail.jsonl", Enumerable.Repeat(UserLine, 40).ToArray());
await host.LoadAsync(path);
var items = host.View.FindControl<ItemsControl>("ChatItems")!;
var point = PresentAndLocate(host, items);
host.Window.MouseMove(point);
host.Window.MouseDown(point, MouseButton.Left);
await host.AppendAndTickAsync(path, 4);
host.Settle();
await Assert.That(host.AtBottom()).IsFalse();
host.Window.MouseDown(point, MouseButton.Right, RawInputModifiers.LeftMouseButton);
host.Window.MouseUp(point, MouseButton.Right, RawInputModifiers.LeftMouseButton);
await host.AppendAndTickAsync(path, 2);
host.Settle();
await Assert.That(host.AtBottom()).IsFalse();
if (loseCapture) {
IPointer? pointer = null;
host.Window.AddHandler(InputElement.PointerMovedEvent, (_, e) => pointer = e.Pointer, RoutingStrategies.Tunnel);
host.Window.MouseMove(point, RawInputModifiers.LeftMouseButton);
pointer!.Capture(items);
pointer.Capture(null);
} else {
var release = releaseOutside ? PresentAndLocate(host, host.Composer) : point;
host.Window.MouseUp(release, MouseButton.Left);
}
host.Settle();
await Assert.That(host.AtBottom()).IsTrue();
if (loseCapture) host.Window.MouseUp(point, MouseButton.Left);
} finally { await host.CloseAsync(); }
});

/// A labelled row shows its verb phrase beside the detail; once the result lands the same item
/// renders as a card with the page's name and its actions, and the plain row is gone.
[Test]
Expand Down
Loading