Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Markdown rendering for snip descriptions.** A snip's description is now
rendered as Markdown (headings, bold/italic, inline and block code, links,
ordered/unordered lists) in the copy flyout, instead of being hidden. The
Markdown is parsed in Core (`MarkdownParser`, backed by Markdig) into a
UI-free model that the WinUI head maps onto native text; the editor labels
the field as Markdown. A snip with a description but no parameters now opens
the flyout too, so its description is always visible before copying.
- **Git-derived version numbers (Nerdbank.GitVersioning).** Every build now
stamps a real version (`0.1.0-alpha.<git-height>+<commit>`) into all
assemblies from a single `version.json`, so the About page shows a meaningful
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<ItemGroup>
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Jdenticon-net" Version="3.1.2" />
<PackageVersion Include="Markdig" Version="1.2.0" />
</ItemGroup>

<!-- App (WinUI 3 head) -->
Expand Down
199 changes: 199 additions & 0 deletions src/Snipdeck.App/Controls/MarkdownPresenter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Media;

using Snipdeck.Core.Services.Markdown;

namespace Snipdeck.App.Controls
{
/// <summary>
/// Renders a markdown string into WinUI text elements. The parsing lives in
/// Core (<see cref="MarkdownParser"/>); this control only maps the resulting
/// UI-free model onto WinUI inlines and blocks. It builds itself as a vertical
/// stack, so no control template is needed.
/// </summary>
public sealed partial class MarkdownPresenter : StackPanel
{
private static readonly FontFamily _codeFont = new("Cascadia Mono, Consolas, Courier New");

public static readonly DependencyProperty MarkdownProperty =
DependencyProperty.Register(
nameof(Markdown),
typeof(string),
typeof(MarkdownPresenter),
new PropertyMetadata(null, OnMarkdownChanged));

public MarkdownPresenter()
{
Spacing = 8;
}

public string? Markdown
{
get => (string?)GetValue(MarkdownProperty);
set => SetValue(MarkdownProperty, value);
}

private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((MarkdownPresenter)d).Rebuild();
}

private void Rebuild()
{
Children.Clear();
foreach (var block in MarkdownParser.Parse(Markdown))
{
var element = BuildBlock(block);
if (element is not null)
{
Children.Add(element);
}
}
}

private static FrameworkElement? BuildBlock(MarkdownBlock block)
{
switch (block)
{
case HeadingBlock heading:
{
var text = NewTextBlock(heading.Level <= 2 ? "SubtitleTextBlockStyle" : "BodyStrongTextBlockStyle");
AddInlines(text.Inlines, heading.Inlines);
return text;
}
case ParagraphBlock paragraph:
{
var text = NewTextBlock(styleKey: null);
AddInlines(text.Inlines, paragraph.Inlines);
return text;
}
case CodeBlock code:
return BuildCodeBlock(code.Text);
case ListBlock list:
return BuildList(list);
default:
return null;
}
}

private static TextBlock NewTextBlock(string? styleKey)
{
var text = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
IsTextSelectionEnabled = true,
};
if (styleKey is not null
&& Application.Current.Resources.TryGetValue(styleKey, out var style)
&& style is Style typed)
{
text.Style = typed;
}
return text;
}

private static Border BuildCodeBlock(string code)
{
var text = new TextBlock
{
Text = code,
FontFamily = _codeFont,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
IsTextSelectionEnabled = true,
};
var border = new Border
{
CornerRadius = new CornerRadius(4),
Padding = new Thickness(8, 6, 8, 6),
Child = text,
};
if (Application.Current.Resources.TryGetValue("SubtleFillColorSecondaryBrush", out var brush)
&& brush is Brush typed)
{
border.Background = typed;
}
return border;
}

private static StackPanel BuildList(ListBlock list)
{
var container = new StackPanel { Spacing = 4 };
for (var index = 0; index < list.Items.Count; index++)
{
var marker = list.Ordered ? $"{index + 1}." : "•";
var row = new Grid();
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });

var markerText = new TextBlock { Text = marker, Margin = new Thickness(0, 0, 8, 0) };
Grid.SetColumn(markerText, 0);

var itemText = new TextBlock { TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true };
AddInlines(itemText.Inlines, list.Items[index].Inlines);
Grid.SetColumn(itemText, 1);

row.Children.Add(markerText);
row.Children.Add(itemText);
container.Children.Add(row);
}
return container;
}

private static void AddInlines(InlineCollection target, IReadOnlyList<MarkdownInline> inlines)
{
foreach (var inline in inlines)
{
switch (inline)
{
case TextRun run:
target.Add(BuildRun(run.Text, run.Bold, run.Italic, run.Code));
break;
case LinkRun link:
target.Add(BuildLink(link));
break;
case LineBreakRun:
target.Add(new LineBreak());
break;
default:
break;
}
}
}

private static Run BuildRun(string text, bool bold, bool italic, bool code)
{
var run = new Run { Text = text };
if (bold)
{
run.FontWeight = Microsoft.UI.Text.FontWeights.Bold;
}
if (italic)
{
run.FontStyle = Windows.UI.Text.FontStyle.Italic;
}
if (code)
{
run.FontFamily = _codeFont;
}
return run;
}

private static Inline BuildLink(LinkRun link)
{
var inner = BuildRun(link.Text, link.Bold, link.Italic, code: false);
// Only wrap in a Hyperlink when the URL is a usable absolute URI;
// otherwise fall back to plain text so a malformed link can't break
// rendering.
if (Uri.TryCreate(link.Url, UriKind.Absolute, out var uri))
{
var hyperlink = new Hyperlink { NavigateUri = uri };
hyperlink.Inlines.Add(inner);
return hyperlink;
}
return inner;
}
}
}
5 changes: 5 additions & 0 deletions src/Snipdeck.App/Views/ParameterFillDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
x:Class="Snipdeck.App.Views.ParameterFillDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Snipdeck.App.Controls"
xmlns:converters="using:Snipdeck.App.Converters"
xmlns:vm="using:Snipdeck.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
Expand All @@ -18,6 +19,10 @@
</ContentDialog.Resources>

<StackPanel MinWidth="480" Spacing="12">
<controls:MarkdownPresenter
Markdown="{x:Bind ViewModel.Description}"
Visibility="{x:Bind ViewModel.HasDescription, Converter={StaticResource BoolToVisibility}}" />

<ItemsControl ItemsSource="{x:Bind ViewModel.Inputs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
Expand Down
3 changes: 2 additions & 1 deletion src/Snipdeck.App/Views/SnipEditorDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
AcceptsReturn="True"
TextWrapping="Wrap" />

<TextBox Header="Description"
<TextBox Header="Description (Markdown)"
PlaceholderText="Supports Markdown — **bold**, `code`, lists, links"
Text="{x:Bind ViewModel.Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True"
TextWrapping="Wrap"
Expand Down
37 changes: 37 additions & 0 deletions src/Snipdeck.Core/Services/Markdown/MarkdownModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Snipdeck.Core.Services.Markdown
{
/// <summary>
/// A small, UI-free representation of rendered markdown. The Core parser
/// produces this model from markdown source; a UI head maps it onto its own
/// native text primitives (WinUI inlines, etc.). Keeping the model here lets
/// the parsing be unit-tested without any UI dependency.
/// </summary>
public abstract record MarkdownBlock;

/// <summary>A run of body text made up of styled inline spans.</summary>
public sealed record ParagraphBlock(IReadOnlyList<MarkdownInline> Inlines) : MarkdownBlock;

/// <summary>A heading (<paramref name="Level"/> 1-6) made up of inline spans.</summary>
public sealed record HeadingBlock(int Level, IReadOnlyList<MarkdownInline> Inlines) : MarkdownBlock;

/// <summary>A fenced or indented code block, rendered verbatim in a monospace box.</summary>
public sealed record CodeBlock(string Text) : MarkdownBlock;

/// <summary>An ordered or unordered list. Items are flattened to inline content.</summary>
public sealed record ListBlock(bool Ordered, IReadOnlyList<ListItem> Items) : MarkdownBlock;

/// <summary>A single list item's inline content.</summary>
public sealed record ListItem(IReadOnlyList<MarkdownInline> Inlines);

/// <summary>An inline span within a block.</summary>
public abstract record MarkdownInline;

/// <summary>A span of text carrying emphasis flags. <paramref name="Code"/> marks inline code.</summary>
public sealed record TextRun(string Text, bool Bold = false, bool Italic = false, bool Code = false) : MarkdownInline;

/// <summary>A hyperlink span.</summary>
public sealed record LinkRun(string Text, string Url, bool Bold = false, bool Italic = false) : MarkdownInline;

/// <summary>A hard line break within a paragraph.</summary>
public sealed record LineBreakRun : MarkdownInline;
}
Loading
Loading