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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user