diff --git a/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs b/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs index 5daa2e4b8..d32cb6370 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/Markdown/ChatMarkdownRenderer.cs @@ -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(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(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(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) @@ -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> + s_blocksCache = new(); + + private static RichTextBlockElement TextRunRichBlock(IReadOnlyList blocks) => + RichTextBlock().Set(rtb => + { + rtb.TextWrapping = TextWrapping.Wrap; + rtb.IsTextSelectionEnabled = true; + ApplyBlocks(rtb, blocks); + }); + + private static void ApplyBlocks(RichTextBlock rtb, IReadOnlyList 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(), 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(), indent, marker, leadGap)); + } + } + + private static Paragraph BuildListParagraph( + RichTextBlock rtb, IReadOnlyList 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 a, IReadOnlyList 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(quote.Children.Count); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index ffcc47817..9349e6866 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -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 + 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 @@ -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; @@ -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; diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs index 5b83efad7..9fa9703f5 100644 --- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs +++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs @@ -185,6 +185,12 @@ public static GridSize Parse(string value) } public sealed record TextBlockElement(string Text) : Element; +// A RichTextBlock host. Unlike TextBlockElement it carries no text of its own — +// its Blocks (Paragraphs / InlineUIContainers) are populated imperatively by a +// caller-supplied setter (see the Set(Action) overload), exactly +// as TextBlockElement's Inlines are. Used by the chat renderer to give a whole +// message one continuous text-selection scope across multiple paragraphs. +public sealed record RichTextBlockElement : Element; public sealed record TextFieldElement(string Value, Action? OnChanged, string? Placeholder, string? Header) : Element; public sealed record PasswordBoxElement(string Password, Action? OnChanged, string? Placeholder) : Element; public sealed record ButtonElement(string Label, Action? OnClick, Element? ContentElement = null) : Element @@ -568,6 +574,7 @@ public static class Factories { public static BorderElement Empty() => new(null); public static TextBlockElement TextBlock(string text) => new(text); + public static RichTextBlockElement RichTextBlock() => new(); public static TextBlockElement Caption(string text) => TextBlock(text).FontSize(12); public static TextFieldElement TextField(string value, Action? onChanged = null, string? placeholder = null, string? header = null) => new(value, onChanged, placeholder, header); @@ -744,6 +751,7 @@ public static TextBlockElement SemiBold(this TextBlockElement element) => element.FontWeight(Microsoft.UI.Text.FontWeights.SemiBold); public static TextBlockElement Set(this TextBlockElement element, Action setter) => element.AddSetter(setter); + public static RichTextBlockElement Set(this RichTextBlockElement element, Action setter) => element.AddSetter(setter); public static TextFieldElement Set(this TextFieldElement element, Action setter) => element.AddSetter(setter); public static PasswordBoxElement Set(this PasswordBoxElement element, Action setter) => element.AddSetter(setter); public static ButtonElement Set(this ButtonElement element, Action