using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; using Markdig; using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace AgentWikiUi.Gui; /// Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui. class MarkdownView : Decorator { public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().DisableHtml().Build(); static readonly FontFamily Mono = new("Cascadia Code, Consolas, DejaVu Sans Mono, Liberation Mono, monospace"); public string? Source { get => GetValue(SourceProperty); set => SetValue(SourceProperty, value); } public MarkdownView() { // i colori si risolvono in fase di rendering: al cambio di tema si ricostruisce ActualThemeVariantChanged += (_, _) => Rebuild(); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == SourceProperty) Rebuild(); } void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10); IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray; Control RenderBlocks(IEnumerable blocks, double spacing) { var panel = new StackPanel { Spacing = spacing }; foreach (var b in blocks) panel.Children.Add(RenderBlock(b)); return panel; } Control RenderBlock(Block block) => block switch { HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }), ParagraphBlock p => Text(p.Inline), FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) }, ListBlock l => List(l), Table t => TableOf(t), ThematicBreakBlock => new Border { Height = 1, Background = Brush("Border"), Margin = new(0, 6) }, LeafBlock leaf => Text(leaf.Inline), ContainerBlock c => RenderBlocks(c, 8), _ => new Panel(), }; Control List(ListBlock list) { var panel = new StackPanel { Spacing = 4 }; var n = int.TryParse(list.OrderedStart, out var start) ? start : 1; foreach (var item in list.OfType()) { var row = new Grid { ColumnDefinitions = new("Auto,*") }; var marker = new TextBlock { Text = list.IsOrdered ? $"{n++}." : "•", Foreground = Brush("FgMuted"), Margin = new(0, 0, 8, 0), MinWidth = 18, TextAlignment = TextAlignment.Right }; var body = RenderBlocks(item, 4); Grid.SetColumn(body, 1); row.Children.Add(marker); row.Children.Add(body); panel.Children.Add(row); } return panel; } Control Code(string code) { var text = new SelectableTextBlock { Text = code, FontFamily = Mono, FontSize = 13.5, Padding = new(14, 12, 64, 12) }; var copy = new Button { Content = "Copia", Classes = { "mini" }, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top, Margin = new(0, 6, 6, 0) }; copy.Click += async (_, _) => { if (TopLevel.GetTopLevel(this)?.Clipboard is { } cb) await cb.SetTextAsync(code); copy.Content = "Copiato"; await Task.Delay(1400); copy.Content = "Copia"; }; return new Border { Background = Brush("Muted"), BorderBrush = Brush("Border"), BorderThickness = new(1), CornerRadius = new(10), Child = new Grid { Children = { new ScrollViewer { HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, Content = text }, copy } }, }; } Control TableOf(Table table) { var grid = new Grid(); var rows = table.OfType().ToList(); var cols = rows.Max(r => r.Count); for (var c = 0; c < cols; c++) grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); for (var r = 0; r < rows.Count; r++) { grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); var cells = rows[r].OfType().ToList(); for (var c = 0; c < cells.Count; c++) { var cell = RenderBlocks(cells[c], 4); if (cell is StackPanel { Children: [SelectableTextBlock tb] }) // intestazione in grassetto if (rows[r].IsHeader) tb.FontWeight = FontWeight.SemiBold; var align = c < table.ColumnDefinitions.Count ? table.ColumnDefinitions[c].Alignment : null; cell.HorizontalAlignment = align switch { TableColumnAlign.Center => HorizontalAlignment.Center, TableColumnAlign.Right => HorizontalAlignment.Right, _ => HorizontalAlignment.Left }; var box = new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), Padding = new(10, 6), Background = rows[r].IsHeader ? Brush("Muted") : null, Child = cell, }; Grid.SetRow(box, r); Grid.SetColumn(box, c); grid.Children.Add(box); } } return new Border { BorderBrush = Brush("Border"), BorderThickness = new(0.5), CornerRadius = new(6), ClipToBounds = true, Child = grid }; } // Testo con inline; i link sono Run colorati e il clic si risolve con l'hit-test sul testo. SelectableTextBlock Text(ContainerInline? inline, bool bold = false, double size = 0) { var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; if (size > 0) tb.FontSize = size; if (bold) tb.FontWeight = FontWeight.SemiBold; var links = new List<(int Start, int End, string Url)>(); var pos = 0; AddInlines(tb.Inlines!, inline, new(), links, ref pos); if (links.Count > 0) WireLinks(tb, links); return tb; } record struct Style(bool Bold, bool Italic, bool Strike, bool Link); void AddInlines(InlineCollection target, ContainerInline? container, Style st, List<(int, int, string)> links, ref int pos) { if (container is null) return; foreach (var node in container) { switch (node) { case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break; case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break; case CodeInline code: target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") }); pos += code.Content.Length; break; case EmphasisInline em: var inner = em.DelimiterChar == '~' ? st with { Strike = true } : em.DelimiterCount >= 2 ? st with { Bold = true } : st with { Italic = true }; AddInlines(target, em, inner, links, ref pos); break; case LinkInline { IsImage: true } img: Add(target, "[" + string.Concat(img.OfType().Select(l => l.Content.ToString())) + "]", st, ref pos); break; case LinkInline link: var start = pos; AddInlines(target, link, st with { Link = true }, links, ref pos); if (link.Url is { } url) links.Add((start, pos, url)); break; case AutolinkInline auto: var s = pos; Add(target, auto.Url, st with { Link = true }, ref pos); links.Add((s, pos, auto.Url)); break; case ContainerInline c: AddInlines(target, c, st, links, ref pos); break; default: break; } } } void Add(InlineCollection target, string text, Style st, ref int pos) { var run = new Run(text); if (st.Bold) run.FontWeight = FontWeight.Bold; if (st.Italic) run.FontStyle = FontStyle.Italic; if (st.Strike) run.TextDecorations = TextDecorations.Strikethrough; if (st.Link) { run.Foreground = Brush("Accent"); run.TextDecorations = TextDecorations.Underline; } target.Add(run); pos += text.Length; } void WireLinks(SelectableTextBlock tb, List<(int Start, int End, string Url)> links) { string? UrlAt(Point p) { var i = tb.TextLayout.HitTestPoint(p).TextPosition; foreach (var (s, e, url) in links) if (i >= s && i < e) return url; return null; } tb.PointerMoved += (_, e) => tb.Cursor = UrlAt(e.GetPosition(tb)) is null ? null : new Cursor(StandardCursorType.Hand); tb.PointerReleased += async (_, e) => { if (tb.SelectionStart != tb.SelectionEnd) return; // era una selezione, non un clic if (UrlAt(e.GetPosition(tb)) is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") if (TopLevel.GetTopLevel(this)?.Launcher is { } l) await l.LaunchUriAsync(uri); }; } }