using System; using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Threading; using ClaudeDo.Ui.Localization; namespace ClaudeDo.Ui.Views.Controls; public partial class MarkdownView : UserControl { public static readonly StyledProperty MarkdownProperty = AvaloniaProperty.Register(nameof(Markdown)); /// /// Renders a bullet that starts with inline code as a command row: the command as a /// monospace chip with a copy button, the rest as a description below it, and a rule under /// each ## section. Opt-in — the prompt-file viewer and the weekly report render /// prose where nearly every bullet carries inline code, and copy buttons there are noise. /// public static readonly StyledProperty CommandRowsProperty = AvaloniaProperty.Register(nameof(CommandRows)); public string? Markdown { get => GetValue(MarkdownProperty); set => SetValue(MarkdownProperty, value); } public bool CommandRows { get => GetValue(CommandRowsProperty); set => SetValue(CommandRowsProperty, value); } private StackPanel? _host; public MarkdownView() { InitializeComponent(); _host = this.FindControl("Host"); } private void InitializeComponent() => AvaloniaXamlLoader.Load(this); protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == MarkdownProperty) Render(change.GetNewValue()); else if (change.Property == CommandRowsProperty) Render(Markdown); } private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled); private void Render(string? md) { _host ??= this.FindControl("Host"); if (_host is null) return; _host.Children.Clear(); if (string.IsNullOrWhiteSpace(md)) return; var lines = md.Replace("\r\n", "\n").Split('\n'); // In CommandRows mode every "## " opens a collapsible section and blocks land inside it; // otherwise this stays the host for the whole document. var target = _host; int i = 0; while (i < lines.Length) { var line = lines[i]; if (string.IsNullOrWhiteSpace(line)) { i++; continue; } if (line.StartsWith("### ")) { target.Children.Add(Heading(line[4..], 13)); i++; continue; } if (line.StartsWith("## ")) { if (CommandRows) target = OpenSection(line[3..]); else target.Children.Add(Heading(line[3..], 15)); i++; continue; } if (line.StartsWith("# ")) { target.Children.Add(Heading(line[2..], 17)); i++; continue; } if (line.StartsWith("- ") || line.StartsWith("* ")) { while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* "))) { var item = lines[i][2..]; target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc) ? CommandRow(cmd, desc) : Bullet(item)); i++; } continue; } var num = NumberedItem.Match(line); if (num.Success) { while (i < lines.Length && NumberedItem.IsMatch(lines[i])) { var m = NumberedItem.Match(lines[i]); target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value)); i++; } continue; } var paragraph = new System.Text.StringBuilder(); while (i < lines.Length && !string.IsNullOrWhiteSpace(lines[i]) && !IsBlockStart(lines[i])) { if (paragraph.Length > 0) paragraph.Append(' '); paragraph.Append(lines[i].Trim()); i++; } target.Children.Add(Paragraph(paragraph.ToString())); } } private static bool IsBlockStart(string line) => line.StartsWith("# ") || line.StartsWith("## ") || line.StartsWith("### ") || line.StartsWith("- ") || line.StartsWith("* ") || NumberedItem.IsMatch(line); private static Control Heading(string text, double size) { var tb = new SelectableTextBlock { FontSize = size, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 2), }; AppendInlines(tb.Inlines!, text); return tb; } /// /// Adds a collapsible section for a ## heading and returns the panel its blocks go /// into. Native — it carries the chevron, keyboard toggle and /// animation, and the app already uses it unstyled in the worktrees overview. /// private StackPanel OpenSection(string title) { var header = new TextBlock { FontSize = 15, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap, }; AppendInlines(header.Inlines!, title); var body = new StackPanel { Spacing = 6 }; _host!.Children.Add(new Expander { Header = header, Content = body, IsExpanded = true, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Stretch, }); return body; } /// /// Splits `command` — description into its two parts. False when the bullet doesn't /// open with inline code, so prose bullets keep the plain renderer. /// internal static bool TrySplitCommand(string text, out string command, out string description) { command = ""; description = ""; if (text.Length < 3 || text[0] != '`') return false; var close = text.IndexOf('`', 1); if (close <= 1) return false; command = text[1..close]; description = text[(close + 1)..].TrimStart(' ', '—', '–', '-', ':').Trim(); return true; } private Control CommandRow(string command, string description) { var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), Margin = new Thickness(0, 2, 0, 2), }; var text = new SelectableTextBlock { Text = command, TextWrapping = TextWrapping.Wrap }; // Bound, not resolved: Render can run while detached, where a resource lookup finds // nothing and would freeze on the fallback. text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("MonoFont")); text.Bind(TextBlock.FontSizeProperty, this.GetResourceObservable("FontSizeMono")); var chip = new Border { CornerRadius = new CornerRadius(4), Padding = new Thickness(7, 4), HorizontalAlignment = HorizontalAlignment.Left, Child = text, }; chip.Bind(Border.BackgroundProperty, this.GetResourceObservable("Surface2Brush")); var stack = new StackPanel { Spacing = 3, Children = { chip } }; if (description.Length > 0) { var desc = new TextBlock { Classes = { "body" }, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0), }; AppendInlines(desc.Inlines!, description); stack.Children.Add(desc); } Grid.SetColumn(stack, 0); grid.Children.Add(stack); var copy = CopyButton(command); Grid.SetColumn(copy, 1); grid.Children.Add(copy); return grid; } private Button CopyButton(string command) { var copyIcon = new PathIcon { Width = 11, Height = 11 }; copyIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Copy")); var checkIcon = new PathIcon { Width = 11, Height = 11 }; checkIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Check")); var button = new Button { Classes = { "icon-btn" }, Content = copyIcon, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(6, 0, 0, 0), }; ToolTip.SetTip(button, Loc.T("controls.markdown.copy")); button.Click += async (_, _) => { if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; await clipboard.SetTextAsync(command); // The clipboard gives no feedback of its own; a brief check mark is the confirmation. button.Content = checkIcon; DispatcherTimer.RunOnce(() => button.Content = copyIcon, TimeSpan.FromSeconds(1.5)); }; return button; } private static Control Paragraph(string text) { var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; AppendInlines(tb.Inlines!, text); return tb; } private static Control Bullet(string text) { var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("14,*") }; var dot = new TextBlock { Text = "•", VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 0, 4, 0), }; Grid.SetColumn(dot, 0); grid.Children.Add(dot); var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; AppendInlines(tb.Inlines!, text); Grid.SetColumn(tb, 1); grid.Children.Add(tb); return grid; } private static Control Numbered(string num, string text) { var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("20,*") }; var lbl = new TextBlock { Text = $"{num}.", VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 0, 4, 0), }; Grid.SetColumn(lbl, 0); grid.Children.Add(lbl); var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; AppendInlines(tb.Inlines!, text); Grid.SetColumn(tb, 1); grid.Children.Add(tb); return grid; } private static void AppendInlines(InlineCollection inlines, string text) { int i = 0; var plain = new System.Text.StringBuilder(); void FlushPlain() { if (plain.Length == 0) return; inlines.Add(new Run(plain.ToString())); plain.Clear(); } while (i < text.Length) { if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*') { int close = text.IndexOf("**", i + 2, System.StringComparison.Ordinal); if (close > i + 2) { FlushPlain(); inlines.Add(new Run(text[(i + 2)..close]) { FontWeight = FontWeight.Bold }); i = close + 2; continue; } } if (text[i] == '*') { int close = text.IndexOf('*', i + 1); if (close > i + 1) { FlushPlain(); inlines.Add(new Run(text[(i + 1)..close]) { FontStyle = FontStyle.Italic }); i = close + 1; continue; } } if (text[i] == '`') { int close = text.IndexOf('`', i + 1); if (close > i + 1) { FlushPlain(); inlines.Add(new Run(text[(i + 1)..close]) { FontFamily = new FontFamily("Consolas,Menlo,monospace"), Background = new SolidColorBrush(Color.FromArgb(60, 127, 127, 127)), }); i = close + 1; continue; } } plain.Append(text[i]); i++; } FlushPlain(); } }