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
316 changes: 308 additions & 8 deletions src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,74 @@ public static class ChatMarkdownRenderer
if (document.Blocks.Count == 0)
return null;

if (document.Blocks.Count == 1)
return RenderBlock(document.Blocks[0]);

var children = new List<Element?>(document.Blocks.Count);
foreach (var block in document.Blocks)
// Coalesce maximal runs of consecutive mergeable blocks (paragraphs,
// headings, and "simple" bullet/numbered lists) into a single
// RichTextBlock so the whole run is one continuous text-selection scope
// — a drag can select across paragraphs AND list items. Blocks that
// carry their own chrome (code cards, table grids, block-quote borders,
// rules) — and any list that contains such a block — stay as their own
// selectable sibling controls, keeping their existing layout.
var blocks = document.Blocks;
var segments = new List<Element?>(blocks.Count);
int i = 0;
while (i < blocks.Count)
{
var rendered = RenderBlock(block);
if (rendered is not null) children.Add(rendered);
if (IsMergeable(blocks[i]))
{
int start = i;
while (i < blocks.Count && IsMergeable(blocks[i])) i++;
int count = i - start;
if (count == 1 && blocks[start] is MdParagraph or MdHeading)
{
// A lone paragraph / heading keeps the lightweight
// single-TextBlock shape (already internally selectable) —
// no behavior change.
var single = RenderBlock(blocks[start]);
if (single is not null) segments.Add(single);
}
else
{
// Multi-block prose runs — and any run containing a list —
// become one continuous RichTextBlock so paragraphs,
// headings, and list items share a single selection scope.
var run = new List<MdBlock>(count);
for (int j = start; j < i; j++) run.Add(blocks[j]);
segments.Add(TextRunRichBlock(run));
}
}
else
{
var rendered = RenderBlock(blocks[i]);
if (rendered is not null) segments.Add(rendered);
i++;
}
}
return VStack(8.0, children.ToArray());

if (segments.Count == 0) return null;
if (segments.Count == 1) return segments[0];
return VStack(8.0, segments.ToArray());
}

// Blocks that can flow into one continuous RichTextBlock: prose (paragraphs,
// headings) and "simple" lists whose items contain only more mergeable
// blocks. A list containing a code block, table, block quote, thematic
// break, or raw block is NOT mergeable — it keeps its own layout as a
// sibling island so that chrome (code cards, table grids) survives and the
// list's non-text children stay selectable in their native form.
private static bool IsMergeable(MdBlock block) => block switch
{
MdParagraph => true,
MdHeading => true,
MdList list => IsSimpleList(list),
_ => false,
};

private static bool IsSimpleList(MdList list)
{
foreach (var item in list.Items)
foreach (var child in item.Children)
if (!IsMergeable(child)) return false;
return true;
}

public static Element? Render(string? markdown)
Expand Down Expand Up @@ -245,6 +303,248 @@ private static Element RenderHeading(MdHeading heading)
private static Element RenderParagraph(MdParagraph paragraph) =>
InlinesTextBlock(paragraph.Inlines).FontSize(BodyFontSize);

// ────────────────────────────────────────────────────────────────────
// Coalesced text run → single selectable RichTextBlock
// ────────────────────────────────────────────────────────────────────

private const double ParagraphSpacing = 8.0;

// Cache the block run applied to each RichTextBlock so re-renders with
// identical content skip the Blocks.Clear()+rebuild round trip. Clearing
// Blocks while the user has an active selection wipes it (same failure mode
// the TextBlock inline cache guards against). Equality is structural
// (see BlockRunsEqual) so it holds even after the bounded AST cache evicts
// and re-parses an unchanged message into fresh block instances.
private static readonly ConditionalWeakTable<RichTextBlock, IReadOnlyList<MdBlock>>
s_blocksCache = new();

private static RichTextBlockElement TextRunRichBlock(IReadOnlyList<MdBlock> blocks) =>
RichTextBlock().Set(rtb =>
{
rtb.TextWrapping = TextWrapping.Wrap;
rtb.IsTextSelectionEnabled = true;
ApplyBlocks(rtb, blocks);
});

private static void ApplyBlocks(RichTextBlock rtb, IReadOnlyList<MdBlock> blocks)
{
if (rtb.Blocks.Count > 0
&& s_blocksCache.TryGetValue(rtb, out var cached)
&& BlockRunsEqual(cached, blocks))
{
return;
}
s_blocksCache.AddOrUpdate(rtb, blocks);
rtb.Blocks.Clear();
foreach (var block in blocks)
{
switch (block)
{
case MdParagraph:
case MdHeading:
{
var p = BuildParagraph(block);
p.Margin = new Thickness(0, TopGap(rtb, ParagraphSpacing), 0, 0);
rtb.Blocks.Add(p);
break;
}
case MdList list:
AppendList(rtb, list, level: 1);
break;
}
}
}

// Vertical gap before the next paragraph: none for the very first paragraph
// in the RichTextBlock, otherwise the supplied gap. Keeping this off the
// first paragraph avoids a leading blank strip at the top of the bubble.
private static double TopGap(RichTextBlock rtb, double gap) =>
rtb.Blocks.Count == 0 ? 0.0 : gap;

// ────────────────────────────────────────────────────────────────────
// Lists coalesced into the shared RichTextBlock (hanging-indent
// paragraphs) — one continuous selection scope with the surrounding
// prose. Only reached for "simple" lists (see IsSimpleList); lists that
// carry chrome-bearing children render via RenderList as their own island.
// ────────────────────────────────────────────────────────────────────

private const double ListIndentPerLevel = 18.0;
private const double ListItemSpacing = 3.0;
// Fixed offset from a list item's marker column to its text/content column.
// Applied as a hanging indent (negative TextIndent on the marker line) so
// wrapped lines and continuation paragraphs align under the text rather than
// back at the marker — approximating the Grid path's Auto/Star columns with
// the flat Paragraph model (which has no per-item measured marker width).
private const double ListHangingIndent = 18.0;
// Marker → text separator. En space gives a stable, font-independent gap
// after the bullet / number without relying on tab stops (which Paragraph
// does not expose).
private const string ListMarkerGap = "\u2002";

private static void AppendList(RichTextBlock rtb, MdList list, int level)
{
bool ordered = list.Marker == MdListMarker.Ordered;
int number = ordered ? list.StartNumber : 0;
for (int idx = 0; idx < list.Items.Count; idx++)
{
var item = list.Items[idx];
string marker = item.TaskState switch
{
MdTaskState.Checked => "\u2611",
MdTaskState.Unchecked => "\u2610",
_ => ordered ? $"{number}." : "\u2022",
};
if (ordered) number++;
// A top-level list's first item gets the fuller paragraph gap so the
// list separates from preceding prose; every other item uses the
// tighter inter-item gap.
double leadGap = (level == 1 && idx == 0) ? ParagraphSpacing : ListItemSpacing;
AppendListItem(rtb, item, marker, level, leadGap);
}
}

private static void AppendListItem(
RichTextBlock rtb, MdListItem item, string marker, int level, double leadGap)
{
double indent = ListIndentPerLevel * level;
bool markerEmitted = false;
foreach (var child in item.Children)
{
double gap = markerEmitted ? ListItemSpacing : leadGap;
switch (child)
{
case MdParagraph p:
rtb.Blocks.Add(BuildListParagraph(
rtb, p.Inlines, indent, markerEmitted ? null : marker, gap));
markerEmitted = true;
break;
case MdHeading h:
{
int hidx = Math.Clamp(h.Level - 1, 0, HeadingFontSizes.Length - 1);
rtb.Blocks.Add(BuildListParagraph(
rtb, h.Inlines, indent, markerEmitted ? null : marker, gap,
HeadingFontSizes[hidx], semiBold: true));
markerEmitted = true;
break;
}
case MdList nested:
if (!markerEmitted)
{
rtb.Blocks.Add(BuildListParagraph(
rtb, Array.Empty<MdInline>(), indent, marker, leadGap));
markerEmitted = true;
}
AppendList(rtb, nested, level + 1);
break;
}
}
if (!markerEmitted)
{
// Empty item — still emit the marker so numbering stays consistent.
rtb.Blocks.Add(BuildListParagraph(
rtb, Array.Empty<MdInline>(), indent, marker, leadGap));
}
}

private static Paragraph BuildListParagraph(
RichTextBlock rtb, IReadOnlyList<MdInline> inlines, double indent,
string? marker, double gap, double fontSize = BodyFontSize, bool semiBold = false)
{
var p = new Paragraph
{
FontSize = fontSize,
// Content column sits a fixed hanging offset past the marker indent
// so wrapped lines / continuation paragraphs align under the text.
Margin = new Thickness(indent + ListHangingIndent, TopGap(rtb, gap), 0, 0),
};
if (semiBold) p.FontWeight = MUIText.FontWeights.SemiBold;
if (marker is not null)
{
// Hanging indent: pull the marker line back to the marker column
// while wrapped lines stay at the content column set by Margin.
p.TextIndent = -ListHangingIndent;
p.Inlines.Add(new Run { Text = marker + ListMarkerGap });
}
AppendInlines(p.Inlines, inlines);
return p;
}

// Compare coalesced text runs structurally rather than via record equality.
// MdParagraph / MdHeading records compare their MdInline lists by reference,
// so two equivalent-but-distinct block instances (produced when the bounded
// GetOrParse AST cache evicts and re-parses an unchanged message) would
// compare unequal and needlessly rebuild Blocks — wiping the active
// selection. MdInline subtypes are value-comparable, so comparing the inline
// sequences by value keeps the selection-preservation guarantee independent
// of parser-cache residency.
private static bool BlockRunsEqual(IReadOnlyList<MdBlock> a, IReadOnlyList<MdBlock> b)
{
if (ReferenceEquals(a, b)) return true;
if (a.Count != b.Count) return false;
for (int i = 0; i < a.Count; i++)
{
if (!BlockEqual(a[i], b[i])) return false;
}
return true;
}

private static bool BlockEqual(MdBlock a, MdBlock b) => (a, b) switch
{
(MdHeading ha, MdHeading hb) => ha.Level == hb.Level && ha.Inlines.SequenceEqual(hb.Inlines),
(MdParagraph pa, MdParagraph pb) => pa.Inlines.SequenceEqual(pb.Inlines),
(MdList la, MdList lb) => ListEqual(la, lb),
_ => a.Equals(b),
};

// Structural list comparison mirroring BlockEqual's rationale: MdList /
// MdListItem records compare their child collections BY REFERENCE, so a
// re-parsed-but-identical list would otherwise force a Blocks rebuild and
// wipe the active selection. Recurse through items and their block children
// (nested lists included) comparing inline content by value.
private static bool ListEqual(MdList a, MdList b)
{
if (a.Marker != b.Marker || a.StartNumber != b.StartNumber) return false;
if (a.Items.Count != b.Items.Count) return false;
for (int i = 0; i < a.Items.Count; i++)
{
var ia = a.Items[i];
var ib = b.Items[i];
if (ia.TaskState != ib.TaskState) return false;
if (ia.Children.Count != ib.Children.Count) return false;
for (int j = 0; j < ia.Children.Count; j++)
if (!BlockEqual(ia.Children[j], ib.Children[j])) return false;
}
return true;
}

private static Paragraph BuildParagraph(MdBlock block)
{
switch (block)
{
case MdHeading heading:
{
int idx = Math.Clamp(heading.Level - 1, 0, HeadingFontSizes.Length - 1);
var p = new Paragraph
{
FontSize = HeadingFontSizes[idx],
FontWeight = MUIText.FontWeights.SemiBold,
};
AppendInlines(p.Inlines, heading.Inlines);
return p;
}
case MdParagraph paragraph:
{
var p = new Paragraph { FontSize = BodyFontSize };
AppendInlines(p.Inlines, paragraph.Inlines);
return p;
}
default:
// Only paragraphs / headings reach this method — lists flow
// through AppendList, other blocks render as sibling islands.
return new Paragraph { FontSize = BodyFontSize };
}
}

private static Element RenderBlockQuote(MdBlockQuote quote)
{
var children = new List<Element?>(quote.Children.Count);
Expand Down
38 changes: 29 additions & 9 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,28 @@ private static void ApplyPlainSelectableInlines(TextBlock textBlock, string? tex
textBlock.Inlines.Add(new Run { Text = normalized });
}

// RichTextBlock analog of the user-bubble plain-text cache. Selection lives
// on the RichTextBlock, so re-applying identical text must NOT clear Blocks
// (that wipes the active selection). Skip the rebuild when the run is
// unchanged and a paragraph is still present.
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<RichTextBlock, string>
s_userParagraphCache = new();

private static void ApplyPlainSelectableParagraph(RichTextBlock richTextBlock, string? text)
{
var normalized = text ?? string.Empty;
if (richTextBlock.Blocks.Count > 0
&& s_userParagraphCache.TryGetValue(richTextBlock, out var cached)
&& cached == normalized)
return;
s_userParagraphCache.AddOrUpdate(richTextBlock, normalized);
richTextBlock.Blocks.Clear();
var paragraph = new Paragraph();
if (normalized.Length > 0)
paragraph.Inlines.Add(new Run { Text = normalized });
richTextBlock.Blocks.Add(paragraph);
}

// Cache parsed markdown text per TextBlock to avoid re-clearing and
// rebuilding Inlines on every re-render when message content is stable.
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<TextBlock, string>
Expand Down Expand Up @@ -1155,7 +1177,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
bool isHighContrast = TryDetectHighContrast();
var selectionHighlightBrush = GetUserBubbleSelectionBrush(isHighContrast);
bubbleChildren.Add(
TextBlock(string.Empty)
RichTextBlock()
.Set(t =>
{
t.TextWrapping = TextWrapping.Wrap;
Expand Down Expand Up @@ -1187,14 +1209,12 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
// color the OS guarantees contrasts with both
// surfaces.
t.SelectionHighlightColor = selectionHighlightBrush;
// Render through Inlines (a single Run) rather
// than the .Text property. This matches the
// assistant bubble's selection-safe path and
// sidesteps a WinUI bug where setting Text on a
// selection-enabled TextBlock during a re-render
// triggered by the mouse-up that ends a drag-
// select leaves the glyph layer visually empty.
ApplyPlainSelectableInlines(t, messageText);
// Render the message as a single Paragraph (one Run)
// so the whole user message is one continuous
// selection scope — matching the assistant bubble's
// RichTextBlock append-block pattern. The plain-text
// run keeps the bubble inert (no markdown / links).
ApplyPlainSelectableParagraph(t, messageText);
}));
}
Element content;
Expand Down
Loading
Loading