55using System . Linq ;
66using System . Net . Http ;
77using System . Text . Json ;
8- using System . Linq ;
98using System . Text . RegularExpressions ;
109using System . Threading . Tasks ;
1110using System . Windows ;
11+ using System . Windows . Documents ;
12+ using System . Windows . Media ;
13+ using System . Windows . Navigation ;
1214using CommunityToolkit . Mvvm . Input ;
1315using TidyWindow . App . Services ;
1416using WindowsClipboard = System . Windows . Clipboard ;
@@ -50,6 +52,8 @@ public sealed class SettingsViewModel : ViewModelBase
5052 private bool _hasFetchedFullReleaseNotes ;
5153 private bool _isReleaseNotesDialogVisible ;
5254 private IReadOnlyList < ReleaseNoteLine > _releaseNotesDisplayLines = Array . Empty < ReleaseNoteLine > ( ) ;
55+ private string _releaseNotesMarkdown = string . Empty ;
56+ private FlowDocument ? _releaseNotesDocument ;
5357
5458 public SettingsViewModel (
5559 MainViewModel mainViewModel ,
@@ -289,7 +293,9 @@ public bool IsReleaseNotesDialogVisible
289293
290294 public IReadOnlyList < ReleaseNoteLine > ReleaseNotesDisplayLines => _releaseNotesDisplayLines ;
291295
292- public bool HasReleaseNotesContent => _releaseNotesDisplayLines . Count > 0 ;
296+ public FlowDocument ? ReleaseNotesDocument => _releaseNotesDocument ;
297+
298+ public bool HasReleaseNotesContent => _releaseNotesDocument is not null ;
293299
294300 public bool HasReleaseNotes => HasReleaseNotesContent || HasReleaseNotesLink ;
295301
@@ -624,7 +630,7 @@ private void RaiseUpdateProperties()
624630
625631 private void RefreshReleaseNotesContent ( )
626632 {
627- _releaseNotesDisplayLines = BuildReleaseNotesLines ( _updateResult ? . Summary ) ;
633+ SetReleaseNotesMarkdown ( _updateResult ? . Summary ) ;
628634 _hasFetchedFullReleaseNotes = false ;
629635 _ = TryFetchFullReleaseNotesAsync ( ) ;
630636
@@ -633,12 +639,6 @@ private void RefreshReleaseNotesContent()
633639 IsReleaseNotesDialogVisible = false ;
634640 }
635641
636- OnPropertyChanged ( nameof ( ReleaseNotesDisplayLines ) ) ;
637- OnPropertyChanged ( nameof ( HasReleaseNotesContent ) ) ;
638- OnPropertyChanged ( nameof ( HasReleaseNotes ) ) ;
639- OnPropertyChanged ( nameof ( ReleaseNotesDialogTitle ) ) ;
640- OnPropertyChanged ( nameof ( ReleaseNotesDialogSubtitle ) ) ;
641-
642642 UpdateReleaseNotesCommands ( ) ;
643643 }
644644
@@ -676,10 +676,7 @@ private async Task<bool> TryFetchFullReleaseNotesAsync()
676676
677677 await System . Windows . Application . Current . Dispatcher . InvokeAsync ( ( ) =>
678678 {
679- _releaseNotesDisplayLines = parsed ;
680- OnPropertyChanged ( nameof ( ReleaseNotesDisplayLines ) ) ;
681- OnPropertyChanged ( nameof ( HasReleaseNotesContent ) ) ;
682- OnPropertyChanged ( nameof ( HasReleaseNotes ) ) ;
679+ SetReleaseNotesMarkdown ( payload . Body ) ;
683680 UpdateReleaseNotesCommands ( ) ;
684681 } ) ;
685682 _hasFetchedFullReleaseNotes = true ;
@@ -699,6 +696,20 @@ private void UpdateReleaseNotesCommands()
699696 OpenReleaseNotesLinkCommand . NotifyCanExecuteChanged ( ) ;
700697 }
701698
699+ private void SetReleaseNotesMarkdown ( string ? markdown )
700+ {
701+ _releaseNotesMarkdown = NormalizeMarkdown ( markdown ) ;
702+ _releaseNotesDisplayLines = BuildReleaseNotesLines ( _releaseNotesMarkdown ) ;
703+ _releaseNotesDocument = BuildReleaseNotesDocument ( _releaseNotesMarkdown ) ;
704+
705+ OnPropertyChanged ( nameof ( ReleaseNotesDisplayLines ) ) ;
706+ OnPropertyChanged ( nameof ( ReleaseNotesDocument ) ) ;
707+ OnPropertyChanged ( nameof ( HasReleaseNotesContent ) ) ;
708+ OnPropertyChanged ( nameof ( HasReleaseNotes ) ) ;
709+ OnPropertyChanged ( nameof ( ReleaseNotesDialogTitle ) ) ;
710+ OnPropertyChanged ( nameof ( ReleaseNotesDialogSubtitle ) ) ;
711+ }
712+
702713 private async Task EnsureFullReleaseNotesAsync ( )
703714 {
704715 if ( _hasFetchedFullReleaseNotes )
@@ -717,12 +728,12 @@ private async Task EnsureFullReleaseNotesAsync()
717728
718729 private bool NeedsFullReleaseNotesFetch ( )
719730 {
720- if ( _releaseNotesDisplayLines . Count == 0 )
731+ if ( string . IsNullOrWhiteSpace ( _releaseNotesMarkdown ) )
721732 {
722733 return true ;
723734 }
724735
725- return _releaseNotesDisplayLines . Any ( line => line . Text . Contains ( "..." , StringComparison . Ordinal ) ) ;
736+ return _releaseNotesMarkdown . Contains ( "..." , StringComparison . Ordinal ) ;
726737 }
727738
728739 private bool CanShowReleaseNotes ( ) => HasReleaseNotes ;
@@ -754,7 +765,9 @@ private void CopyReleaseNotes()
754765
755766 try
756767 {
757- var text = string . Join ( Environment . NewLine , _releaseNotesDisplayLines . Select ( line => $ "{ line . Icon } { line . Text } ") ) ;
768+ var text = string . IsNullOrWhiteSpace ( _releaseNotesMarkdown )
769+ ? string . Join ( Environment . NewLine , _releaseNotesDisplayLines . Select ( line => $ "{ line . Icon } { line . Text } ") )
770+ : _releaseNotesMarkdown ;
758771 WindowsClipboard . SetText ( text ) ;
759772 PublishStatus ( "Release notes copied to the clipboard." ) ;
760773 }
@@ -791,7 +804,7 @@ private static IReadOnlyList<ReleaseNoteLine> BuildReleaseNotesLines(string? sum
791804 return Array . Empty < ReleaseNoteLine > ( ) ;
792805 }
793806
794- var normalized = summary . Replace ( " \r \n " , " \n " ) ;
807+ var normalized = NormalizeLineEndings ( NormalizeMarkdown ( summary ) ) ;
795808 var segments = Regex . Split ( normalized , @"(?:\r?\n|\s+-\s*|^\s*-\s*)" )
796809 . Select ( part => part . Trim ( ) )
797810 . Where ( part => part . Length > 0 )
@@ -853,6 +866,269 @@ private static string ResolveReleaseNoteIcon(string text)
853866 return "•" ;
854867 }
855868
869+ private static FlowDocument ? BuildReleaseNotesDocument ( string ? markdown )
870+ {
871+ if ( string . IsNullOrWhiteSpace ( markdown ) )
872+ {
873+ return null ;
874+ }
875+
876+ var document = new FlowDocument
877+ {
878+ PagePadding = new Thickness ( 0 ) ,
879+ FontFamily = new System . Windows . Media . FontFamily ( "Segoe UI" ) ,
880+ FontSize = 13 ,
881+ Foreground = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 226 , 232 , 240 ) )
882+ } ;
883+
884+ System . Windows . Documents . List ? activeList = null ;
885+ var isActiveListOrdered = false ;
886+ var normalized = NormalizeLineEndings ( NormalizeMarkdown ( markdown ) ) ;
887+ var lines = normalized . Split ( '\n ' ) ;
888+
889+ void FlushList ( )
890+ {
891+ if ( activeList is not null )
892+ {
893+ document . Blocks . Add ( activeList ) ;
894+ activeList = null ;
895+ }
896+ }
897+
898+ foreach ( var rawLine in lines )
899+ {
900+ var line = rawLine . TrimEnd ( ) ;
901+
902+ if ( string . IsNullOrWhiteSpace ( line ) )
903+ {
904+ FlushList ( ) ;
905+ continue ;
906+ }
907+
908+ if ( TryParseHeading ( line , out var level , out var headingText ) )
909+ {
910+ FlushList ( ) ;
911+ document . Blocks . Add ( CreateHeadingBlock ( headingText , level ) ) ;
912+ continue ;
913+ }
914+
915+ if ( TryParseListItem ( line , out var isOrdered , out var listText ) )
916+ {
917+ if ( activeList is null || isOrdered != isActiveListOrdered )
918+ {
919+ FlushList ( ) ;
920+ activeList = CreateList ( isOrdered ) ;
921+ isActiveListOrdered = isOrdered ;
922+ }
923+
924+ activeList . ListItems . Add ( new ListItem ( CreateParagraphWithIcon ( listText , includeIcon : true ) ) ) ;
925+ continue ;
926+ }
927+
928+ FlushList ( ) ;
929+ document . Blocks . Add ( CreateParagraphWithIcon ( line , includeIcon : false ) ) ;
930+ }
931+
932+ FlushList ( ) ;
933+ return document ;
934+ }
935+
936+ private static System . Windows . Documents . List CreateList ( bool isOrdered ) => new ( )
937+ {
938+ MarkerStyle = TextMarkerStyle . None ,
939+ Margin = new Thickness ( 0 , 0 , 0 , 8 )
940+ } ;
941+
942+ private static Paragraph CreateHeadingBlock ( string text , int level )
943+ {
944+ var paragraph = new Paragraph
945+ {
946+ Margin = new Thickness ( 0 , level == 1 ? 0 : 6 , 0 , 4 )
947+ } ;
948+
949+ paragraph . Inlines . Add ( new Run ( text )
950+ {
951+ FontSize = level switch
952+ {
953+ 1 => 18 ,
954+ 2 => 16 ,
955+ 3 => 15 ,
956+ _ => 14
957+ } ,
958+ FontWeight = System . Windows . FontWeights . SemiBold ,
959+ Foreground = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 248 , 250 , 252 ) )
960+ } ) ;
961+
962+ return paragraph ;
963+ }
964+
965+ private static Paragraph CreateParagraphWithIcon ( string text , bool includeIcon )
966+ {
967+ var paragraph = new Paragraph
968+ {
969+ Margin = new Thickness ( 0 , 0 , 0 , 8 )
970+ } ;
971+
972+ if ( includeIcon )
973+ {
974+ paragraph . Inlines . Add ( new Run ( $ "{ ResolveReleaseNoteIcon ( text ) } ")
975+ {
976+ Foreground = ReleaseNoteIconBrush ,
977+ FontWeight = System . Windows . FontWeights . SemiBold
978+ } ) ;
979+ }
980+
981+ AppendMarkdownInlines ( paragraph . Inlines , text ) ;
982+ return paragraph ;
983+ }
984+
985+ private static void AppendMarkdownInlines ( InlineCollection inlines , string text )
986+ {
987+ if ( string . IsNullOrEmpty ( text ) )
988+ {
989+ return ;
990+ }
991+
992+ const string inlinePattern = @"(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))" ;
993+ var matches = Regex . Matches ( text , inlinePattern ) ;
994+ var index = 0 ;
995+
996+ foreach ( Match match in matches )
997+ {
998+ if ( match . Index > index )
999+ {
1000+ inlines . Add ( new Run ( text . Substring ( index , match . Index - index ) ) ) ;
1001+ }
1002+
1003+ var value = match . Value ;
1004+ if ( value . StartsWith ( "`" , StringComparison . Ordinal ) )
1005+ {
1006+ var code = value . Trim ( '`' ) ;
1007+ var codeSpan = new Span ( new Run ( code ) )
1008+ {
1009+ FontFamily = new System . Windows . Media . FontFamily ( "Cascadia Mono" ) ,
1010+ Background = InlineCodeBackgroundBrush ,
1011+ Foreground = InlineCodeForegroundBrush
1012+ } ;
1013+ inlines . Add ( codeSpan ) ;
1014+ }
1015+ else if ( value . StartsWith ( "**" , StringComparison . Ordinal ) )
1016+ {
1017+ inlines . Add ( new Run ( value [ 2 ..^ 2 ] ) { FontWeight = System . Windows . FontWeights . SemiBold } ) ;
1018+ }
1019+ else if ( value . StartsWith ( "*" , StringComparison . Ordinal ) )
1020+ {
1021+ inlines . Add ( new Run ( value [ 1 ..^ 1 ] ) { FontStyle = System . Windows . FontStyles . Italic } ) ;
1022+ }
1023+ else if ( value . StartsWith ( "[" , StringComparison . Ordinal ) && match . Groups . Count >= 3 )
1024+ {
1025+ var linkText = match . Groups [ 2 ] . Value ;
1026+ var linkTarget = match . Groups [ 3 ] . Value ;
1027+ Uri ? uri = null ;
1028+
1029+ if ( ! Uri . TryCreate ( linkTarget , UriKind . Absolute , out uri ) )
1030+ {
1031+ Uri . TryCreate ( $ "https://{ linkTarget } ", UriKind . Absolute , out uri ) ;
1032+ }
1033+
1034+ if ( uri is null )
1035+ {
1036+ inlines . Add ( new Run ( linkText ) ) ;
1037+ }
1038+ else
1039+ {
1040+ var hyperlink = new Hyperlink ( new Run ( linkText ) )
1041+ {
1042+ NavigateUri = uri ,
1043+ Foreground = ReleaseNoteLinkBrush
1044+ } ;
1045+
1046+ hyperlink . RequestNavigate += OnHyperlinkNavigate ;
1047+ inlines . Add ( hyperlink ) ;
1048+ }
1049+ }
1050+
1051+ index = match . Index + match . Length ;
1052+ }
1053+
1054+ if ( index < text . Length )
1055+ {
1056+ inlines . Add ( new Run ( text [ index ..] ) ) ;
1057+ }
1058+ }
1059+
1060+ private static bool TryParseHeading ( string line , out int level , out string text )
1061+ {
1062+ level = 0 ;
1063+ text = string . Empty ;
1064+
1065+ if ( ! line . StartsWith ( "#" , StringComparison . Ordinal ) )
1066+ {
1067+ return false ;
1068+ }
1069+
1070+ var match = Regex . Match ( line , @"^(#{1,6})\s+(.*)$" ) ;
1071+ if ( ! match . Success )
1072+ {
1073+ return false ;
1074+ }
1075+
1076+ level = Math . Clamp ( match . Groups [ 1 ] . Value . Length , 1 , 6 ) ;
1077+ text = match . Groups [ 2 ] . Value . Trim ( ) ;
1078+ return true ;
1079+ }
1080+
1081+ private static bool TryParseListItem ( string line , out bool isOrdered , out string text )
1082+ {
1083+ var unorderedMatch = Regex . Match ( line , @"^[-*+]\s+(.*)$" ) ;
1084+ if ( unorderedMatch . Success )
1085+ {
1086+ isOrdered = false ;
1087+ text = unorderedMatch . Groups [ 1 ] . Value . Trim ( ) ;
1088+ return true ;
1089+ }
1090+
1091+ var orderedMatch = Regex . Match ( line , @"^\d+\.\s+(.*)$" ) ;
1092+ if ( orderedMatch . Success )
1093+ {
1094+ isOrdered = true ;
1095+ text = orderedMatch . Groups [ 1 ] . Value . Trim ( ) ;
1096+ return true ;
1097+ }
1098+
1099+ isOrdered = false ;
1100+ text = string . Empty ;
1101+ return false ;
1102+ }
1103+
1104+ private static void OnHyperlinkNavigate ( object ? sender , RequestNavigateEventArgs e )
1105+ {
1106+ try
1107+ {
1108+ Process . Start ( new ProcessStartInfo ( e . Uri . AbsoluteUri ) { UseShellExecute = true } ) ;
1109+ }
1110+ catch
1111+ {
1112+ // If navigation fails, keep the dialog open so the user can copy the link manually.
1113+ }
1114+
1115+ e . Handled = true ;
1116+ }
1117+
1118+ private static string NormalizeMarkdown ( string ? markdown )
1119+ {
1120+ return string . IsNullOrWhiteSpace ( markdown )
1121+ ? string . Empty
1122+ : markdown . Trim ( ) ;
1123+ }
1124+
1125+ private static string NormalizeLineEndings ( string text ) => text . Replace ( "\r \n " , "\n " ) ;
1126+
1127+ private static readonly System . Windows . Media . Brush ReleaseNoteIconBrush = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 250 , 204 , 21 ) ) ;
1128+ private static readonly System . Windows . Media . Brush ReleaseNoteLinkBrush = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 96 , 165 , 250 ) ) ;
1129+ private static readonly System . Windows . Media . Brush InlineCodeBackgroundBrush = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 30 , 41 , 59 ) ) ;
1130+ private static readonly System . Windows . Media . Brush InlineCodeForegroundBrush = new SolidColorBrush ( System . Windows . Media . Color . FromRgb ( 226 , 232 , 240 ) ) ;
1131+
8561132 public sealed record ReleaseNoteLine ( string Icon , string Text ) ;
8571133
8581134 private sealed record GitHubReleaseResponse ( string ? Body ) ;
0 commit comments