Sechs Markdown-Sheets (git, Conventional Commits, Claude CLI, Claude-Config, dotnet CLI, PowerShell) als EmbeddedResource, gerendert im modelosen, per Topmost anpinnbaren CheatSheetsWindow. MarkdownView bekommt dafuer das Opt-in CommandRows: `befehl` - beschreibung wird zu Mono-Chip + Copy-Button, jede ##-Sektion landet in einem Expander. Enthaelt ausserdem: Mission Control wird beim Oeffnen aus dem Minimiert-Zustand geholt, nicht nur aktiviert.
376 lines
13 KiB
C#
376 lines
13 KiB
C#
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<string?> MarkdownProperty =
|
||
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
|
||
|
||
/// <summary>
|
||
/// 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 <c>##</c> 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.
|
||
/// </summary>
|
||
public static readonly StyledProperty<bool> CommandRowsProperty =
|
||
AvaloniaProperty.Register<MarkdownView, bool>(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<StackPanel>("Host");
|
||
}
|
||
|
||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||
|
||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||
{
|
||
base.OnPropertyChanged(change);
|
||
if (change.Property == MarkdownProperty)
|
||
Render(change.GetNewValue<string?>());
|
||
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<StackPanel>("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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Adds a collapsible section for a <c>##</c> heading and returns the panel its blocks go
|
||
/// into. Native <see cref="Expander"/> — it carries the chevron, keyboard toggle and
|
||
/// animation, and the app already uses it unstyled in the worktrees overview.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Splits <c>`command` — description</c> into its two parts. False when the bullet doesn't
|
||
/// open with inline code, so prose bullets keep the plain renderer.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|