Initial commit to new repository
This commit is contained in:
824
src/AxCopilot/Services/MarkdownRenderer.cs
Normal file
824
src/AxCopilot/Services/MarkdownRenderer.cs
Normal file
@@ -0,0 +1,824 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 간이 마크다운 → WPF UIElement 변환기.
|
||||
/// 지원: **볼드**, *이탤릭*, `인라인코드`, ```코드블록```, ### 헤더, - 리스트, --- 구분선
|
||||
/// </summary>
|
||||
public static class MarkdownRenderer
|
||||
{
|
||||
public static StackPanel Render(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
|
||||
{
|
||||
var panel = new StackPanel();
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
return panel;
|
||||
|
||||
var lines = markdown.Replace("\r\n", "\n").Split('\n');
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
// 코드 블록 ```
|
||||
if (line.TrimStart().StartsWith("```"))
|
||||
{
|
||||
var lang = line.TrimStart().Length > 3 ? line.TrimStart()[3..].Trim() : "";
|
||||
var codeLines = new System.Text.StringBuilder();
|
||||
i++;
|
||||
while (i < lines.Length && !lines[i].TrimStart().StartsWith("```"))
|
||||
{
|
||||
codeLines.AppendLine(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.Length) i++; // skip closing ```
|
||||
|
||||
var codeBlock = CreateCodeBlock(codeLines.ToString().TrimEnd(), lang, textColor, codeBg, accentColor);
|
||||
panel.Children.Add(codeBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 빈 줄
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
panel.Children.Add(new Border { Height = 6 });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 구분선 ---
|
||||
if (Regex.IsMatch(line.Trim(), @"^-{3,}$|^\*{3,}$"))
|
||||
{
|
||||
panel.Children.Add(new Border
|
||||
{
|
||||
Height = 1,
|
||||
Background = secondaryColor,
|
||||
Opacity = 0.3,
|
||||
Margin = new Thickness(0, 8, 0, 8)
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 헤더 ###
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
var level = 0;
|
||||
while (level < line.Length && line[level] == '#') level++;
|
||||
var headerText = line[level..].Trim();
|
||||
var fontSize = level switch { 1 => 20.0, 2 => 17.0, 3 => 15.0, _ => 14.0 };
|
||||
|
||||
var tb = new TextBlock
|
||||
{
|
||||
FontSize = fontSize,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = textColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, level == 1 ? 12 : 8, 0, 4)
|
||||
};
|
||||
AddInlines(tb.Inlines, headerText, textColor, accentColor, codeBg);
|
||||
panel.Children.Add(tb);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 리스트 항목 - 또는 * 또는 숫자.
|
||||
if (Regex.IsMatch(line, @"^\s*[-*]\s") || Regex.IsMatch(line, @"^\s*\d+\.\s"))
|
||||
{
|
||||
var match = Regex.Match(line, @"^(\s*)([-*]|\d+\.)\s(.*)");
|
||||
if (match.Success)
|
||||
{
|
||||
var indent = match.Groups[1].Value.Length / 2;
|
||||
var bullet = match.Groups[2].Value;
|
||||
var content = match.Groups[3].Value;
|
||||
|
||||
var bulletChar = bullet is "-" or "*" ? "•" : bullet;
|
||||
var grid = new Grid
|
||||
{
|
||||
Margin = new Thickness(12 + indent * 16, 2, 0, 2)
|
||||
};
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(18) });
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var bulletTb = new TextBlock
|
||||
{
|
||||
Text = bulletChar,
|
||||
FontSize = 13.5,
|
||||
Foreground = accentColor,
|
||||
VerticalAlignment = VerticalAlignment.Top
|
||||
};
|
||||
Grid.SetColumn(bulletTb, 0);
|
||||
grid.Children.Add(bulletTb);
|
||||
|
||||
var tb = new TextBlock
|
||||
{
|
||||
FontSize = 13.5,
|
||||
Foreground = textColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
VerticalAlignment = VerticalAlignment.Top
|
||||
};
|
||||
Grid.SetColumn(tb, 1);
|
||||
AddInlines(tb.Inlines, content, textColor, accentColor, codeBg);
|
||||
grid.Children.Add(tb);
|
||||
panel.Children.Add(grid);
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 인용 블록 >
|
||||
if (line.TrimStart().StartsWith('>'))
|
||||
{
|
||||
var quoteLines = new List<string>();
|
||||
while (i < lines.Length && lines[i].TrimStart().StartsWith('>'))
|
||||
{
|
||||
var ql = lines[i].TrimStart();
|
||||
quoteLines.Add(ql.Length > 1 ? ql[1..].TrimStart() : "");
|
||||
i++;
|
||||
}
|
||||
var quoteBorder = new Border
|
||||
{
|
||||
BorderBrush = accentColor,
|
||||
BorderThickness = new Thickness(3, 0, 0, 0),
|
||||
Padding = new Thickness(12, 6, 8, 6),
|
||||
Margin = new Thickness(4, 4, 0, 4),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)),
|
||||
};
|
||||
var quoteTb = new TextBlock
|
||||
{
|
||||
FontSize = 13,
|
||||
FontStyle = FontStyles.Italic,
|
||||
Foreground = secondaryColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 20,
|
||||
};
|
||||
AddInlines(quoteTb.Inlines, string.Join("\n", quoteLines), textColor, accentColor, codeBg);
|
||||
quoteBorder.Child = quoteTb;
|
||||
panel.Children.Add(quoteBorder);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 테이블 | col1 | col2 |
|
||||
if (line.Contains('|') && line.Trim().StartsWith('|'))
|
||||
{
|
||||
var tableRows = new List<string>();
|
||||
while (i < lines.Length && lines[i].Contains('|'))
|
||||
{
|
||||
tableRows.Add(lines[i]);
|
||||
i++;
|
||||
}
|
||||
var table = CreateMarkdownTable(tableRows, textColor, accentColor, codeBg);
|
||||
if (table != null) panel.Children.Add(table);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 일반 텍스트 단락
|
||||
var para = new TextBlock
|
||||
{
|
||||
FontSize = 13.5,
|
||||
Foreground = textColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
LineHeight = 22,
|
||||
Margin = new Thickness(0, 2, 0, 2)
|
||||
};
|
||||
AddInlines(para.Inlines, line, textColor, accentColor, codeBg);
|
||||
panel.Children.Add(para);
|
||||
i++;
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// <summary>인라인 마크다운 처리: **볼드**, *이탤릭*, `코드`, ~~취소선~~, [링크](url)</summary>
|
||||
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg)
|
||||
{
|
||||
// 패턴: [link](url) | ~~strikethrough~~ | **bold** | *italic* | `code` | 일반텍스트
|
||||
var pattern = @"(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|([^*`~\[]+))";
|
||||
var matches = Regex.Matches(text, pattern);
|
||||
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
if (m.Groups[2].Success && m.Groups[3].Success) // [link](url)
|
||||
{
|
||||
var linkUrl = m.Groups[3].Value;
|
||||
var hl = new Hyperlink(new Run(m.Groups[2].Value))
|
||||
{
|
||||
Foreground = accentColor,
|
||||
TextDecorations = null,
|
||||
Cursor = System.Windows.Input.Cursors.Hand,
|
||||
};
|
||||
hl.Click += (_, _) =>
|
||||
{
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(linkUrl) { UseShellExecute = true }); } catch { }
|
||||
};
|
||||
inlines.Add(hl);
|
||||
}
|
||||
else if (m.Groups[4].Success) // ~~strikethrough~~
|
||||
{
|
||||
inlines.Add(new Run(m.Groups[4].Value)
|
||||
{
|
||||
TextDecorations = TextDecorations.Strikethrough,
|
||||
Foreground = new SolidColorBrush(Color.FromArgb(0x99, 0xFF, 0xFF, 0xFF)),
|
||||
});
|
||||
}
|
||||
else if (m.Groups[5].Success) // **bold**
|
||||
{
|
||||
inlines.Add(new Run(m.Groups[5].Value) { FontWeight = FontWeights.Bold });
|
||||
}
|
||||
else if (m.Groups[6].Success) // *italic*
|
||||
{
|
||||
inlines.Add(new Run(m.Groups[6].Value) { FontStyle = FontStyles.Italic });
|
||||
}
|
||||
else if (m.Groups[7].Success) // `code`
|
||||
{
|
||||
var codeBorder = new Border
|
||||
{
|
||||
Background = codeBg,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
Margin = new Thickness(1, 0, 1, 0),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = m.Groups[7].Value,
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
Foreground = accentColor
|
||||
}
|
||||
};
|
||||
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
|
||||
}
|
||||
else if (m.Groups[8].Success) // 일반 텍스트
|
||||
{
|
||||
AddPlainTextWithFilePaths(inlines, m.Groups[8].Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>파일 경로 패턴을 파란색 강조하여 추가하는 정적 Regex.</summary>
|
||||
private static readonly Regex FilePathPattern = new(
|
||||
@"(?<![""'=/>])(" +
|
||||
@"[A-Za-z]:\\[^\s<>""',;)]+|" + // 절대 경로: C:\folder\file.ext
|
||||
@"\.{1,2}/[\w\-./]+(?:\.[a-zA-Z]{1,10})?|" + // 상대 경로: ./src/file.cs
|
||||
@"[\w\-]+(?:/[\w\-\.]+){1,}(?:\.[a-zA-Z]{1,10})?|" + // 폴더/파일: src/utils/helper.cs
|
||||
@"[\w\-]+\.(?:cs|py|js|ts|tsx|jsx|json|xml|html|htm|css|md|txt|yml|yaml|toml|sh|bat|ps1|csproj|sln|docx|xlsx|pptx|pdf|csv|enc|skill))" +
|
||||
@"(?=[,\s;)""'<]|$)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>파일 경로 활성 여부 (설정 연동).</summary>
|
||||
public static bool EnableFilePathHighlight { get; set; } = true;
|
||||
|
||||
/// <summary>일반 텍스트에서 파일 경로 패턴을 감지하여 파란색으로 강조합니다.</summary>
|
||||
private static void AddPlainTextWithFilePaths(InlineCollection inlines, string text)
|
||||
{
|
||||
if (!EnableFilePathHighlight)
|
||||
{
|
||||
inlines.Add(new Run(text));
|
||||
return;
|
||||
}
|
||||
|
||||
var matches = FilePathPattern.Matches(text);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
inlines.Add(new Run(text));
|
||||
return;
|
||||
}
|
||||
|
||||
var lastIndex = 0;
|
||||
foreach (Match pm in matches)
|
||||
{
|
||||
// 매치 전 텍스트
|
||||
if (pm.Index > lastIndex)
|
||||
inlines.Add(new Run(text[lastIndex..pm.Index]));
|
||||
|
||||
// 파일 경로 — 파란색 강조
|
||||
inlines.Add(new Run(pm.Value)
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6)), // #3B82F6
|
||||
FontWeight = FontWeights.Medium,
|
||||
});
|
||||
lastIndex = pm.Index + pm.Length;
|
||||
}
|
||||
|
||||
// 남은 텍스트
|
||||
if (lastIndex < text.Length)
|
||||
inlines.Add(new Run(text[lastIndex..]));
|
||||
}
|
||||
|
||||
/// <summary>마크다운 테이블을 Grid로 렌더링합니다.</summary>
|
||||
private static FrameworkElement? CreateMarkdownTable(List<string> rows, Brush textColor, Brush accentColor, Brush codeBg)
|
||||
{
|
||||
if (rows.Count < 2) return null;
|
||||
|
||||
// 셀 파싱
|
||||
static string[] ParseRow(string row)
|
||||
{
|
||||
var trimmed = row.Trim().Trim('|');
|
||||
return trimmed.Split('|').Select(c => c.Trim()).ToArray();
|
||||
}
|
||||
|
||||
var headers = ParseRow(rows[0]);
|
||||
var colCount = headers.Length;
|
||||
if (colCount == 0) return null;
|
||||
|
||||
// 구분선 행(---|---) 건너뛰기
|
||||
var dataStart = 1;
|
||||
if (rows.Count > 1 && Regex.IsMatch(rows[1].Trim(), @"^[\|\s:\-]+$"))
|
||||
dataStart = 2;
|
||||
|
||||
var grid = new Grid { Margin = new Thickness(0, 6, 0, 6) };
|
||||
for (int c = 0; c < colCount; c++)
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var rowIndex = 0;
|
||||
|
||||
// 헤더 행
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
for (int c = 0; c < colCount; c++)
|
||||
{
|
||||
var cell = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x20, 0xFF, 0xFF, 0xFF)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
|
||||
BorderThickness = new Thickness(0, 0, c < colCount - 1 ? 1 : 0, 1),
|
||||
Padding = new Thickness(8, 5, 8, 5),
|
||||
};
|
||||
var tb = new TextBlock
|
||||
{
|
||||
Text = c < headers.Length ? headers[c] : "",
|
||||
FontSize = 12, FontWeight = FontWeights.SemiBold,
|
||||
Foreground = textColor, TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
cell.Child = tb;
|
||||
Grid.SetRow(cell, rowIndex);
|
||||
Grid.SetColumn(cell, c);
|
||||
grid.Children.Add(cell);
|
||||
}
|
||||
rowIndex++;
|
||||
|
||||
// 데이터 행
|
||||
for (int r = dataStart; r < rows.Count; r++)
|
||||
{
|
||||
var cols = ParseRow(rows[r]);
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
for (int c = 0; c < colCount; c++)
|
||||
{
|
||||
var cell = new Border
|
||||
{
|
||||
Background = r % 2 == 0
|
||||
? new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF))
|
||||
: Brushes.Transparent,
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)),
|
||||
BorderThickness = new Thickness(0, 0, c < colCount - 1 ? 1 : 0, 1),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
};
|
||||
var tb = new TextBlock
|
||||
{
|
||||
FontSize = 12, Foreground = textColor, TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
AddInlines(tb.Inlines, c < cols.Length ? cols[c] : "", textColor, accentColor, codeBg);
|
||||
cell.Child = tb;
|
||||
Grid.SetRow(cell, rowIndex);
|
||||
Grid.SetColumn(cell, c);
|
||||
grid.Children.Add(cell);
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
var wrapper = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(6),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
|
||||
BorderThickness = new Thickness(1),
|
||||
ClipToBounds = true,
|
||||
Margin = new Thickness(0, 4, 0, 4),
|
||||
};
|
||||
wrapper.Child = grid;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/// <summary>코드 블록 UI 생성 (헤더 + 복사 버튼 + 코드)</summary>
|
||||
private static Border CreateCodeBlock(string code, string lang, Brush textColor, Brush codeBg, Brush accentColor)
|
||||
{
|
||||
var container = new Border
|
||||
{
|
||||
Background = codeBg,
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Margin = new Thickness(0, 6, 0, 6),
|
||||
Padding = new Thickness(0)
|
||||
};
|
||||
|
||||
var stack = new StackPanel();
|
||||
|
||||
// 헤더 (언어 + 복사 버튼)
|
||||
var header = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)),
|
||||
CornerRadius = new CornerRadius(10, 10, 0, 0),
|
||||
Padding = new Thickness(14, 6, 8, 6)
|
||||
};
|
||||
var headerGrid = new Grid();
|
||||
headerGrid.Children.Add(new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrEmpty(lang) ? "code" : lang,
|
||||
FontSize = 11,
|
||||
Foreground = accentColor,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
|
||||
// 우측 버튼 패널
|
||||
var btnPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
var capturedCode = code;
|
||||
var capturedLang = lang;
|
||||
|
||||
// 파일 저장 버튼
|
||||
var saveBtn = CreateCodeHeaderButton("\uE74E", "저장", textColor);
|
||||
saveBtn.Click += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = GetExtensionForLang(capturedLang);
|
||||
var dlg = new Microsoft.Win32.SaveFileDialog
|
||||
{
|
||||
FileName = $"code{ext}",
|
||||
Filter = $"코드 파일 (*{ext})|*{ext}|모든 파일 (*.*)|*.*",
|
||||
};
|
||||
if (dlg.ShowDialog() == true)
|
||||
System.IO.File.WriteAllText(dlg.FileName, capturedCode);
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
btnPanel.Children.Add(saveBtn);
|
||||
|
||||
// 전체화면 버튼
|
||||
var expandBtn = CreateCodeHeaderButton("\uE740", "확대", textColor);
|
||||
expandBtn.Click += (_, _) => ShowCodeFullScreen(capturedCode, capturedLang, codeBg, textColor);
|
||||
btnPanel.Children.Add(expandBtn);
|
||||
|
||||
// 복사 버튼
|
||||
var copyBtn = CreateCodeHeaderButton("\uE8C8", "복사", textColor);
|
||||
copyBtn.Click += (_, _) => { try { Clipboard.SetText(capturedCode); } catch { } };
|
||||
btnPanel.Children.Add(copyBtn);
|
||||
|
||||
headerGrid.Children.Add(btnPanel);
|
||||
header.Child = headerGrid;
|
||||
stack.Children.Add(header);
|
||||
|
||||
// 코드 본문 (라인 번호 + 구문 하이라이팅)
|
||||
var codeGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
|
||||
codeGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
codeGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 라인 번호
|
||||
var codeLines = code.Split('\n');
|
||||
var lineNumbers = new TextBlock
|
||||
{
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
Foreground = new SolidColorBrush(Color.FromArgb(0x50, 0xFF, 0xFF, 0xFF)),
|
||||
Padding = new Thickness(10, 10, 6, 14),
|
||||
LineHeight = 20,
|
||||
TextAlignment = TextAlignment.Right,
|
||||
Text = string.Join("\n", Enumerable.Range(1, codeLines.Length)),
|
||||
};
|
||||
Grid.SetColumn(lineNumbers, 0);
|
||||
codeGrid.Children.Add(lineNumbers);
|
||||
|
||||
var codeText = new TextBlock
|
||||
{
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
Foreground = textColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Padding = new Thickness(8, 10, 14, 14),
|
||||
LineHeight = 20
|
||||
};
|
||||
ApplySyntaxHighlighting(codeText, code, lang, textColor);
|
||||
Grid.SetColumn(codeText, 1);
|
||||
codeGrid.Children.Add(codeText);
|
||||
|
||||
stack.Children.Add(codeGrid);
|
||||
|
||||
container.Child = stack;
|
||||
return container;
|
||||
}
|
||||
|
||||
private static Button CreateCodeHeaderButton(string mdlIcon, string label, Brush fg)
|
||||
{
|
||||
return new Button
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Cursor = System.Windows.Input.Cursors.Hand,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Padding = new Thickness(5, 2, 5, 2),
|
||||
Margin = new Thickness(2, 0, 0, 0),
|
||||
Content = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = mdlIcon,
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 10, Foreground = fg, Opacity = 0.6,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 3, 0),
|
||||
},
|
||||
new TextBlock { Text = label, FontSize = 10, Foreground = fg, Opacity = 0.6 },
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetExtensionForLang(string lang) => (lang ?? "").ToLowerInvariant() switch
|
||||
{
|
||||
"csharp" or "cs" => ".cs",
|
||||
"python" or "py" => ".py",
|
||||
"javascript" or "js" => ".js",
|
||||
"typescript" or "ts" => ".ts",
|
||||
"java" => ".java",
|
||||
"html" => ".html",
|
||||
"css" => ".css",
|
||||
"json" => ".json",
|
||||
"xml" => ".xml",
|
||||
"sql" => ".sql",
|
||||
"bash" or "sh" or "shell" => ".sh",
|
||||
"powershell" or "ps1" => ".ps1",
|
||||
"bat" or "cmd" => ".bat",
|
||||
"yaml" or "yml" => ".yml",
|
||||
"markdown" or "md" => ".md",
|
||||
"cpp" or "c++" => ".cpp",
|
||||
"c" => ".c",
|
||||
"go" => ".go",
|
||||
"rust" or "rs" => ".rs",
|
||||
_ => ".txt",
|
||||
};
|
||||
|
||||
private static void ShowCodeFullScreen(string code, string lang, Brush codeBg, Brush textColor)
|
||||
{
|
||||
var win = new Window
|
||||
{
|
||||
Title = $"코드 — {(string.IsNullOrEmpty(lang) ? "code" : lang)}",
|
||||
Width = 900, Height = 650,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Background = codeBg is SolidColorBrush scb ? new SolidColorBrush(scb.Color) : Brushes.Black,
|
||||
};
|
||||
|
||||
var grid = new Grid();
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
var lines = code.Split('\n');
|
||||
|
||||
var lineNumTb = new TextBlock
|
||||
{
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 13, LineHeight = 22,
|
||||
Foreground = new SolidColorBrush(Color.FromArgb(0x50, 0xFF, 0xFF, 0xFF)),
|
||||
Padding = new Thickness(16, 16, 8, 16),
|
||||
TextAlignment = TextAlignment.Right,
|
||||
Text = string.Join("\n", Enumerable.Range(1, lines.Length)),
|
||||
};
|
||||
Grid.SetColumn(lineNumTb, 0);
|
||||
grid.Children.Add(lineNumTb);
|
||||
|
||||
var codeTb = new TextBlock
|
||||
{
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 13, LineHeight = 22,
|
||||
Foreground = textColor,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Padding = new Thickness(8, 16, 16, 16),
|
||||
};
|
||||
ApplySyntaxHighlighting(codeTb, code, lang, textColor);
|
||||
Grid.SetColumn(codeTb, 1);
|
||||
grid.Children.Add(codeTb);
|
||||
|
||||
var sv = new ScrollViewer
|
||||
{
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
Content = grid,
|
||||
};
|
||||
win.Content = sv;
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ─── 구문 하이라이팅 ──────────────────────────────────────────────────
|
||||
|
||||
private static readonly Brush KeywordBrush = new SolidColorBrush(Color.FromRgb(0xC5, 0x86, 0xC0)); // purple
|
||||
private static readonly Brush TypeBrush = new SolidColorBrush(Color.FromRgb(0x4E, 0xC9, 0xB0)); // teal
|
||||
private static readonly Brush StringBrush = new SolidColorBrush(Color.FromRgb(0xCE, 0x91, 0x78)); // orange
|
||||
private static readonly Brush CommentBrush = new SolidColorBrush(Color.FromRgb(0x6A, 0x99, 0x55)); // green
|
||||
private static readonly Brush NumberBrush = new SolidColorBrush(Color.FromRgb(0xB5, 0xCE, 0xA8)); // light green
|
||||
private static readonly Brush MethodBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0xDC, 0xAA)); // yellow
|
||||
private static readonly Brush DirectiveBrush = new SolidColorBrush(Color.FromRgb(0x9C, 0xDC, 0xFE)); // light blue
|
||||
|
||||
private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","as","async","await","base","bool","break","byte","case","catch","char","checked",
|
||||
"class","const","continue","decimal","default","delegate","do","double","else","enum","event",
|
||||
"explicit","extern","false","finally","fixed","float","for","foreach","goto","if","implicit",
|
||||
"in","int","interface","internal","is","lock","long","namespace","new","null","object","operator",
|
||||
"out","override","params","private","protected","public","readonly","ref","return","sbyte","sealed",
|
||||
"short","sizeof","stackalloc","static","string","struct","switch","this","throw","true","try",
|
||||
"typeof","uint","ulong","unchecked","unsafe","ushort","using","var","virtual","void","volatile",
|
||||
"while","yield","record","init","required","get","set","value","where","when","and","or","not",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> PythonKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"False","None","True","and","as","assert","async","await","break","class","continue","def",
|
||||
"del","elif","else","except","finally","for","from","global","if","import","in","is","lambda",
|
||||
"nonlocal","not","or","pass","raise","return","try","while","with","yield",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> JsKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","arguments","async","await","boolean","break","byte","case","catch","char","class",
|
||||
"const","continue","debugger","default","delete","do","double","else","enum","eval","export",
|
||||
"extends","false","final","finally","float","for","from","function","goto","if","implements",
|
||||
"import","in","instanceof","int","interface","let","long","native","new","null","of","package",
|
||||
"private","protected","public","return","short","static","super","switch","synchronized","this",
|
||||
"throw","throws","transient","true","try","typeof","undefined","var","void","volatile","while",
|
||||
"with","yield",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> JavaKeywords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","assert","boolean","break","byte","case","catch","char","class","const","continue",
|
||||
"default","do","double","else","enum","extends","false","final","finally","float","for","goto",
|
||||
"if","implements","import","instanceof","int","interface","long","native","new","null","package",
|
||||
"private","protected","public","return","short","static","strictfp","super","switch","synchronized",
|
||||
"this","throw","throws","transient","true","try","void","volatile","while","var","record","sealed",
|
||||
"permits","yield",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SqlKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SELECT","FROM","WHERE","INSERT","INTO","UPDATE","SET","DELETE","CREATE","DROP","ALTER","TABLE",
|
||||
"INDEX","VIEW","JOIN","INNER","LEFT","RIGHT","OUTER","FULL","CROSS","ON","AND","OR","NOT","IN",
|
||||
"IS","NULL","LIKE","BETWEEN","EXISTS","HAVING","GROUP","BY","ORDER","ASC","DESC","LIMIT","OFFSET",
|
||||
"UNION","ALL","DISTINCT","AS","CASE","WHEN","THEN","ELSE","END","VALUES","PRIMARY","KEY","FOREIGN",
|
||||
"REFERENCES","CONSTRAINT","UNIQUE","CHECK","DEFAULT","COUNT","SUM","AVG","MIN","MAX","CAST",
|
||||
"COALESCE","IF","BEGIN","COMMIT","ROLLBACK","GRANT","REVOKE","TRUNCATE","WITH","RECURSIVE",
|
||||
};
|
||||
|
||||
private static HashSet<string> GetKeywordsForLang(string lang) => lang.ToLowerInvariant() switch
|
||||
{
|
||||
"csharp" or "cs" or "c#" => CSharpKeywords,
|
||||
"python" or "py" => PythonKeywords,
|
||||
"javascript" or "js" or "jsx" => JsKeywords,
|
||||
"typescript" or "ts" or "tsx" => JsKeywords,
|
||||
"java" or "kotlin" or "kt" => JavaKeywords,
|
||||
"sql" or "mysql" or "postgresql" or "sqlite" => SqlKeywords,
|
||||
"go" or "golang" => new(StringComparer.Ordinal)
|
||||
{
|
||||
"break","case","chan","const","continue","default","defer","else","fallthrough","for","func",
|
||||
"go","goto","if","import","interface","map","package","range","return","select","struct",
|
||||
"switch","type","var","nil","true","false","append","len","cap","make","new","panic","recover",
|
||||
},
|
||||
"rust" or "rs" => new(StringComparer.Ordinal)
|
||||
{
|
||||
"as","async","await","break","const","continue","crate","dyn","else","enum","extern","false",
|
||||
"fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return",
|
||||
"self","Self","static","struct","super","trait","true","type","unsafe","use","where","while",
|
||||
"yield","Box","Vec","String","Option","Result","Some","None","Ok","Err","println","macro_rules",
|
||||
},
|
||||
"cpp" or "c++" or "c" or "h" or "hpp" => new(StringComparer.Ordinal)
|
||||
{
|
||||
"auto","break","case","char","const","continue","default","do","double","else","enum","extern",
|
||||
"float","for","goto","if","inline","int","long","register","return","short","signed","sizeof",
|
||||
"static","struct","switch","typedef","union","unsigned","void","volatile","while","class",
|
||||
"namespace","using","public","private","protected","virtual","override","template","typename",
|
||||
"nullptr","true","false","new","delete","throw","try","catch","const_cast","dynamic_cast",
|
||||
"static_cast","reinterpret_cast","bool","string","include","define","ifdef","ifndef","endif",
|
||||
},
|
||||
_ => CSharpKeywords, // default
|
||||
};
|
||||
|
||||
private static readonly Regex SyntaxPattern = new(
|
||||
@"(//[^\n]*|#[^\n]*)" + // single-line comment (// or #)
|
||||
@"|(""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*')" + // strings
|
||||
@"|(\b\d+\.?\d*[fFdDmMlL]?\b)" + // numbers
|
||||
@"|(\b[A-Z]\w*(?=\s*[\.<\(]))" + // type-like (PascalCase before . < ()
|
||||
@"|(\b\w+(?=\s*\())" + // method call
|
||||
@"|(\b\w+\b)", // identifier / keyword
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code))
|
||||
{
|
||||
tb.Text = code;
|
||||
return;
|
||||
}
|
||||
|
||||
var keywords = GetKeywordsForLang(lang);
|
||||
var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell"
|
||||
or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1";
|
||||
|
||||
foreach (Match m in SyntaxPattern.Matches(code))
|
||||
{
|
||||
if (m.Groups[1].Success) // comment
|
||||
{
|
||||
var commentText = m.Groups[1].Value;
|
||||
// # 주석은 해당 언어에서만 적용
|
||||
if (commentText.StartsWith('#') && !isCommentHash)
|
||||
{
|
||||
// # 이 주석이 아닌 언어: 일반 텍스트로 처리
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
|
||||
}
|
||||
else
|
||||
{
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
|
||||
}
|
||||
}
|
||||
else if (m.Groups[2].Success) // string
|
||||
{
|
||||
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
|
||||
}
|
||||
else if (m.Groups[3].Success) // number
|
||||
{
|
||||
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
|
||||
}
|
||||
else if (m.Groups[4].Success) // type
|
||||
{
|
||||
var word = m.Groups[4].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
|
||||
}
|
||||
else if (m.Groups[5].Success) // method
|
||||
{
|
||||
var word = m.Groups[5].Value;
|
||||
if (keywords.Contains(word))
|
||||
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
|
||||
else
|
||||
tb.Inlines.Add(new Run(word) { Foreground = MethodBrush });
|
||||
}
|
||||
else if (m.Groups[6].Success) // identifier
|
||||
{
|
||||
var word = m.Groups[6].Value;
|
||||
if (keywords.Contains(word))
|
||||
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
|
||||
else
|
||||
tb.Inlines.Add(new Run(word) { Foreground = defaultColor });
|
||||
}
|
||||
}
|
||||
|
||||
// Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움
|
||||
// Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완
|
||||
// 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근
|
||||
tb.Inlines.Clear();
|
||||
int lastEnd = 0;
|
||||
foreach (Match m in SyntaxPattern.Matches(code))
|
||||
{
|
||||
if (m.Index > lastEnd)
|
||||
tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
|
||||
|
||||
if (m.Groups[1].Success)
|
||||
{
|
||||
var commentText = m.Groups[1].Value;
|
||||
if (commentText.StartsWith('#') && !isCommentHash)
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
|
||||
else
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
|
||||
}
|
||||
else if (m.Groups[2].Success)
|
||||
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
|
||||
else if (m.Groups[3].Success)
|
||||
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
|
||||
else if (m.Groups[4].Success)
|
||||
{
|
||||
var word = m.Groups[4].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
|
||||
}
|
||||
else if (m.Groups[5].Success)
|
||||
{
|
||||
var word = m.Groups[5].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
|
||||
}
|
||||
else if (m.Groups[6].Success)
|
||||
{
|
||||
var word = m.Groups[6].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
|
||||
}
|
||||
|
||||
lastEnd = m.Index + m.Length;
|
||||
}
|
||||
if (lastEnd < code.Length)
|
||||
tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user