Files
agent-wiki-ui/Gui/MarkdownView.cs
T
davideandClaude Sonnet 5.5 941d02110d GUI Avalonia multipiattaforma in solo C# e rinomina in agent-wiki-ui
Sostituisce WinForms, WebView2 e ui.html con una GUI Avalonia (XAML,
ViewModel, tema chiaro/scuro) e un renderer markdown che trasforma l'albero
di Markdig in controlli. Un solo progetto net10.0 per Windows e Linux, con
publish single-file compresso. L'avvio di claude è in un solo punto
(Core/Claude.cs) condiviso da CLI e GUI. Il nome dell'app viene
dall'AssemblyName; ChatStore migra la cartella dati LlmRun alla nuova.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:18:46 +02:00

205 lines
9.6 KiB
C#

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;
/// <summary>Mostra markdown come controlli Avalonia (nessun HTML): il testo è Markdig, il rendering è qui.</summary>
class MarkdownView : Decorator
{
public static readonly StyledProperty<string?> SourceProperty = AvaloniaProperty.Register<MarkdownView, string?>(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<Block> 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<ListItemBlock>())
{
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<TableRow>().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<TableCell>().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<LiteralInline>().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);
};
}
}