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>
205 lines
9.6 KiB
C#
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);
|
|
};
|
|
}
|
|
}
|