825 lines
35 KiB
C#
825 lines
35 KiB
C#
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 });
|
|
}
|
|
}
|