[Phase49] MarkdownRenderer·ChatWindow.MessageRendering 분리

변경 파일:
- MarkdownRenderer.cs: 621 → 405줄 (Render/AddInlines/Table 유지)
- MarkdownRenderer.CodeBlock.cs (신규): CreateCodeBlock, CreateCodeHeaderButton,
  GetExtensionForLang, ShowCodeFullScreen (~218줄)
- ChatWindow.MessageRendering.cs: 522 → 220줄 (RenderMessages/AddMessageBubble 유지)
- ChatWindow.Animations.cs (신규): GetCheckStyle, CreateCheckIcon, AnimateScale,
  ApplyHoverScaleAnimation, ApplyHoverBounceAnimation, CreateSimpleCheck,
  ApplyMenuItemHover (172줄)
- ChatWindow.FeedbackButtons.cs (신규): CreateActionButton, CreateFeedbackButton,
  AddLinkedFeedbackButtons (121줄)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 21:22:47 +09:00
parent c4f23eb2b0
commit 306529a02c
5 changed files with 556 additions and 518 deletions

View File

@@ -0,0 +1,227 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Views;
namespace AxCopilot.Services;
public static partial class MarkdownRenderer
{
/// <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 (Exception) { }
};
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 (Exception) { } };
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.SegoeMdl2,
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.CascadiaCode,
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();
}
}

View File

@@ -402,220 +402,4 @@ public static partial class MarkdownRenderer
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 (Exception) { }
};
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 (Exception) { } };
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.SegoeMdl2,
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 = ThemeResourceHelper.CascadiaCode,
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 = ThemeResourceHelper.CascadiaCode,
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();
}
}

View File

@@ -0,0 +1,199 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 애니메이션 + 체크 헬퍼 ─────────────────────────────────────────
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
private string GetCheckStyle()
{
var theme = (_settings.Settings.Launcher.Theme ?? "system").ToLowerInvariant();
return theme switch
{
"dark" or "system" => "circle", // 원 + 체크마크, 바운스
"oled" => "glow", // 네온 글로우 원, 페이드인
"light" => "roundrect", // 둥근 사각형, 슬라이드인
"nord" => "diamond", // 다이아몬드(마름모), 스무스 스케일
"catppuccin" => "pill", // 필 모양, 스프링 바운스
"monokai" => "square", // 정사각형, 퀵 팝
"sepia" => "stamp", // 도장 스타일 원, 회전 등장
"alfred" => "minimal", // 미니멀 원, 우아한 페이드
"alfredlight" => "minimal", // 미니멀 원, 우아한 페이드
_ => "circle",
};
}
private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null)
{
var accent = accentBrush ?? ThemeResourceHelper.Accent(this);
// 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간
if (isChecked)
{
return CreateSimpleCheck(accent, 14);
}
// 미선택: 동일 크기 빈 공간 (정렬 유지)
return new System.Windows.Shapes.Rectangle
{
Width = 14, Height = 14,
Fill = Brushes.Transparent,
Margin = new Thickness(0, 0, 10, 0),
};
}
/// <summary>ScaleTransform 바운스/스케일 애니메이션 헬퍼.</summary>
private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease)
{
if (el.RenderTransform is TransformGroup tg)
{
var st = tg.Children.OfType<ScaleTransform>().FirstOrDefault();
if (st != null)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
st.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
st.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
return;
}
}
if (el.RenderTransform is ScaleTransform scale)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
}
/// <summary>마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 살짝 확대하는 호버 애니메이션.
/// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다.
/// 독립적 공간이 있는 버튼에만 적용하세요.
/// </summary>
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
{
// Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능
void EnsureTransform()
{
element.RenderTransformOrigin = new Point(0.5, 0.5);
// 봉인(frozen)된 Transform이면 새로 생성하여 교체
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new ScaleTransform(1, 1);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
};
}
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.
/// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다.
/// </summary>
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
{
void EnsureTransform()
{
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new TranslateTransform(0, 0);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } });
};
}
/// <summary>심플한 V 체크 아이콘을 생성합니다 (디자인 통일용).</summary>
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
{
return new System.Windows.Shapes.Path
{
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
Stroke = color,
StrokeThickness = 2,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round,
StrokeLineJoin = PenLineJoin.Round,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center,
};
}
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
private static void ApplyMenuItemHover(Border item)
{
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
if (originalBg.CanFreeze) originalBg.Freeze();
item.RenderTransformOrigin = new Point(0.5, 0.5);
item.RenderTransform = new ScaleTransform(1, 1);
item.MouseEnter += (s, _) =>
{
if (s is Border b)
{
// 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
b.Opacity = 0.85;
else
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
};
item.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Opacity = 1.0;
b.Background = originalBg;
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
};
}
}

View File

@@ -0,0 +1,130 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 액션·피드백 버튼 ───────────────────────────────────────────────
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
{
var hoverBrush = ThemeResourceHelper.Primary(this);
var icon = new TextBlock
{
Text = symbol,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
btn.MouseEnter += (_, _) => icon.Foreground = hoverBrush;
btn.MouseLeave += (_, _) => icon.Foreground = foreground;
btn.Click += (_, _) => onClick();
ApplyHoverScaleAnimation(btn, 1.15);
return btn;
}
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<Action>? registerReset = null)
{
var hoverBrush = ThemeResourceHelper.Primary(this);
var isActive = message?.Feedback == feedbackType;
var icon = new TextBlock
{
Text = isActive ? filled : outline,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = isActive ? activeColor : normalColor,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1)
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
// 상대 버튼이 리셋할 수 있도록 등록
registerReset?.Invoke(() =>
{
isActive = false;
icon.Text = outline;
icon.Foreground = normalColor;
});
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; };
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
btn.Click += (_, _) =>
{
isActive = !isActive;
icon.Text = isActive ? filled : outline;
icon.Foreground = isActive ? activeColor : normalColor;
// 상호 배타: 활성화 시 반대쪽 리셋
if (isActive) resetSibling?.Invoke();
// 피드백 상태 저장
if (message != null)
{
message.Feedback = isActive ? feedbackType : null;
try
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null) _storage.Save(conv);
}
catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
}
// 바운스 애니메이션
var scale = (ScaleTransform)icon.RenderTransform;
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
};
return btn;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
{
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
Action? resetLikeAction = null;
Action? resetDislikeAction = null;
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like",
resetSibling: () => resetDislikeAction?.Invoke(),
registerReset: reset => resetLikeAction = reset);
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike",
resetSibling: () => resetLikeAction?.Invoke(),
registerReset: reset => resetDislikeAction = reset);
actionBar.Children.Add(likeBtn);
actionBar.Children.Add(dislikeBtn);
}
}

View File

@@ -217,306 +217,4 @@ public partial class ChatWindow
}
}
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
private string GetCheckStyle()
{
var theme = (_settings.Settings.Launcher.Theme ?? "system").ToLowerInvariant();
return theme switch
{
"dark" or "system" => "circle", // 원 + 체크마크, 바운스
"oled" => "glow", // 네온 글로우 원, 페이드인
"light" => "roundrect", // 둥근 사각형, 슬라이드인
"nord" => "diamond", // 다이아몬드(마름모), 스무스 스케일
"catppuccin" => "pill", // 필 모양, 스프링 바운스
"monokai" => "square", // 정사각형, 퀵 팝
"sepia" => "stamp", // 도장 스타일 원, 회전 등장
"alfred" => "minimal", // 미니멀 원, 우아한 페이드
"alfredlight" => "minimal", // 미니멀 원, 우아한 페이드
_ => "circle",
};
}
private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null)
{
var accent = accentBrush ?? ThemeResourceHelper.Accent(this);
// 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간
if (isChecked)
{
return CreateSimpleCheck(accent, 14);
}
// 미선택: 동일 크기 빈 공간 (정렬 유지)
return new System.Windows.Shapes.Rectangle
{
Width = 14, Height = 14,
Fill = Brushes.Transparent,
Margin = new Thickness(0, 0, 10, 0),
};
}
/// <summary>ScaleTransform 바운스/스케일 애니메이션 헬퍼.</summary>
private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease)
{
if (el.RenderTransform is TransformGroup tg)
{
var st = tg.Children.OfType<ScaleTransform>().FirstOrDefault();
if (st != null)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
st.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
st.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
return;
}
}
if (el.RenderTransform is ScaleTransform scale)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
}
/// <summary>마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 살짝 확대하는 호버 애니메이션.
/// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다.
/// 독립적 공간이 있는 버튼에만 적용하세요.
/// </summary>
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
{
// Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능
void EnsureTransform()
{
element.RenderTransformOrigin = new Point(0.5, 0.5);
// 봉인(frozen)된 Transform이면 새로 생성하여 교체
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new ScaleTransform(1, 1);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
};
}
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.
/// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다.
/// </summary>
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
{
void EnsureTransform()
{
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new TranslateTransform(0, 0);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } });
};
}
/// <summary>심플한 V 체크 아이콘을 생성합니다 (디자인 통일용).</summary>
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
{
return new System.Windows.Shapes.Path
{
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
Stroke = color,
StrokeThickness = 2,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round,
StrokeLineJoin = PenLineJoin.Round,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center,
};
}
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
private static void ApplyMenuItemHover(Border item)
{
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
if (originalBg.CanFreeze) originalBg.Freeze();
item.RenderTransformOrigin = new Point(0.5, 0.5);
item.RenderTransform = new ScaleTransform(1, 1);
item.MouseEnter += (s, _) =>
{
if (s is Border b)
{
// 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
b.Opacity = 0.85;
else
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
};
item.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Opacity = 1.0;
b.Background = originalBg;
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
};
}
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
{
var hoverBrush = ThemeResourceHelper.Primary(this);
var icon = new TextBlock
{
Text = symbol,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
btn.MouseEnter += (_, _) => icon.Foreground = hoverBrush;
btn.MouseLeave += (_, _) => icon.Foreground = foreground;
btn.Click += (_, _) => onClick();
ApplyHoverScaleAnimation(btn, 1.15);
return btn;
}
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<Action>? registerReset = null)
{
var hoverBrush = ThemeResourceHelper.Primary(this);
var isActive = message?.Feedback == feedbackType;
var icon = new TextBlock
{
Text = isActive ? filled : outline,
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 12,
Foreground = isActive ? activeColor : normalColor,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1)
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
// 상대 버튼이 리셋할 수 있도록 등록
registerReset?.Invoke(() =>
{
isActive = false;
icon.Text = outline;
icon.Foreground = normalColor;
});
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; };
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
btn.Click += (_, _) =>
{
isActive = !isActive;
icon.Text = isActive ? filled : outline;
icon.Foreground = isActive ? activeColor : normalColor;
// 상호 배타: 활성화 시 반대쪽 리셋
if (isActive) resetSibling?.Invoke();
// 피드백 상태 저장
if (message != null)
{
message.Feedback = isActive ? feedbackType : null;
try
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null) _storage.Save(conv);
}
catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
}
// 바운스 애니메이션
var scale = (ScaleTransform)icon.RenderTransform;
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
};
return btn;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
{
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
Action? resetLikeAction = null;
Action? resetDislikeAction = null;
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like",
resetSibling: () => resetDislikeAction?.Invoke(),
registerReset: reset => resetLikeAction = reset);
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike",
resetSibling: () => resetLikeAction?.Invoke(),
registerReset: reset => resetDislikeAction = reset);
actionBar.Children.Add(likeBtn);
actionBar.Children.Add(dislikeBtn);
}
}