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.
This commit is contained in:
mika kuns
2026-09-07 08:17:37 +02:00
parent 7c7c33d677
commit 0db9fadee9
20 changed files with 749 additions and 7 deletions
@@ -1,10 +1,14 @@
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;
@@ -13,12 +17,27 @@ 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()
@@ -34,6 +53,8 @@ public partial class MarkdownView : UserControl
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);
@@ -48,6 +69,9 @@ public partial class MarkdownView : UserControl
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)
{
@@ -56,19 +80,22 @@ public partial class MarkdownView : UserControl
if (line.StartsWith("### "))
{
_host.Children.Add(Heading(line[4..], 13));
target.Children.Add(Heading(line[4..], 13));
i++;
continue;
}
if (line.StartsWith("## "))
{
_host.Children.Add(Heading(line[3..], 15));
if (CommandRows)
target = OpenSection(line[3..]);
else
target.Children.Add(Heading(line[3..], 15));
i++;
continue;
}
if (line.StartsWith("# "))
{
_host.Children.Add(Heading(line[2..], 17));
target.Children.Add(Heading(line[2..], 17));
i++;
continue;
}
@@ -77,7 +104,10 @@ public partial class MarkdownView : UserControl
{
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
{
_host.Children.Add(Bullet(lines[i][2..]));
var item = lines[i][2..];
target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc)
? CommandRow(cmd, desc)
: Bullet(item));
i++;
}
continue;
@@ -89,7 +119,7 @@ public partial class MarkdownView : UserControl
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
{
var m = NumberedItem.Match(lines[i]);
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
i++;
}
continue;
@@ -104,7 +134,7 @@ public partial class MarkdownView : UserControl
paragraph.Append(lines[i].Trim());
i++;
}
_host.Children.Add(Paragraph(paragraph.ToString()));
target.Children.Add(Paragraph(paragraph.ToString()));
}
}
@@ -126,6 +156,120 @@ public partial class MarkdownView : UserControl
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 };