From 306529a02ce430f40d8c7d2a12f1e08ec55f53f9 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 21:22:47 +0900 Subject: [PATCH] =?UTF-8?q?[Phase49]=20MarkdownRenderer=C2=B7ChatWindow.Me?= =?UTF-8?q?ssageRendering=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 파일: - 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 --- .../Services/MarkdownRenderer.CodeBlock.cs | 227 +++++++++++++ src/AxCopilot/Services/MarkdownRenderer.cs | 216 ------------- src/AxCopilot/Views/ChatWindow.Animations.cs | 199 ++++++++++++ .../Views/ChatWindow.FeedbackButtons.cs | 130 ++++++++ .../Views/ChatWindow.MessageRendering.cs | 302 ------------------ 5 files changed, 556 insertions(+), 518 deletions(-) create mode 100644 src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs create mode 100644 src/AxCopilot/Views/ChatWindow.Animations.cs create mode 100644 src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs diff --git a/src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs b/src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs new file mode 100644 index 0000000..706bd1b --- /dev/null +++ b/src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs @@ -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 +{ + /// 코드 블록 UI 생성 (헤더 + 복사 버튼 + 코드) + 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(); + } + +} diff --git a/src/AxCopilot/Services/MarkdownRenderer.cs b/src/AxCopilot/Services/MarkdownRenderer.cs index fbe687d..00b3194 100644 --- a/src/AxCopilot/Services/MarkdownRenderer.cs +++ b/src/AxCopilot/Services/MarkdownRenderer.cs @@ -402,220 +402,4 @@ public static partial class MarkdownRenderer return wrapper; } - /// 코드 블록 UI 생성 (헤더 + 복사 버튼 + 코드) - 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(); - } - } diff --git a/src/AxCopilot/Views/ChatWindow.Animations.cs b/src/AxCopilot/Views/ChatWindow.Animations.cs new file mode 100644 index 0000000..47f6dcb --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.Animations.cs @@ -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 +{ + // ─── 애니메이션 + 체크 헬퍼 ───────────────────────────────────────── + + // ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ───────────────────────── + + /// 커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션. + /// 현재 테마의 체크 스타일을 반환합니다. + 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), + }; + } + + /// ScaleTransform 바운스/스케일 애니메이션 헬퍼. + 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().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); + } + } + + /// 마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다. + /// + /// 마우스 오버 시 살짝 확대하는 호버 애니메이션. + /// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다. + /// 독립적 공간이 있는 버튼에만 적용하세요. + /// + 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); + }; + } + + /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다. + /// + /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션. + /// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다. + /// + 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 } }); + }; + } + + /// 심플한 V 체크 아이콘을 생성합니다 (디자인 통일용). + 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, + }; + } + + /// 팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다. + 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))); + }; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs b/src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs new file mode 100644 index 0000000..c16e937 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs @@ -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; + } + + /// 좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장) + private Button CreateFeedbackButton(string outline, string filled, string tooltip, + Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", + Action? resetSibling = null, 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; + } + + /// 좋아요/싫어요 버튼을 상호 배타로 연결하여 추가 + 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); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.MessageRendering.cs b/src/AxCopilot/Views/ChatWindow.MessageRendering.cs index 8763320..c42a2cb 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageRendering.cs @@ -217,306 +217,4 @@ public partial class ChatWindow } } - // ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ───────────────────────── - - /// 커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션. - /// 현재 테마의 체크 스타일을 반환합니다. - 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), - }; - } - - /// ScaleTransform 바운스/스케일 애니메이션 헬퍼. - 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().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); - } - } - - /// 마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다. - /// - /// 마우스 오버 시 살짝 확대하는 호버 애니메이션. - /// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다. - /// 독립적 공간이 있는 버튼에만 적용하세요. - /// - 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); - }; - } - - /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다. - /// - /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션. - /// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다. - /// - 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 } }); - }; - } - - /// 심플한 V 체크 아이콘을 생성합니다 (디자인 통일용). - 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, - }; - } - - /// 팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다. - 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; - } - - /// 좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장) - private Button CreateFeedbackButton(string outline, string filled, string tooltip, - Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", - Action? resetSibling = null, 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; - } - - /// 좋아요/싫어요 버튼을 상호 배타로 연결하여 추가 - 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); - } }