Files
ClaudeDo/src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs
T
mika kuns 0db9fadee9 feat(ui): Cheat-Sheet-Fenster im Hilfe-Menue
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.
2026-09-07 08:17:37 +02:00

376 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}