[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:
227
src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs
Normal file
227
src/AxCopilot/Services/MarkdownRenderer.CodeBlock.cs
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
199
src/AxCopilot/Views/ChatWindow.Animations.cs
Normal file
199
src/AxCopilot/Views/ChatWindow.Animations.cs
Normal 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)));
|
||||
};
|
||||
}
|
||||
}
|
||||
130
src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs
Normal file
130
src/AxCopilot/Views/ChatWindow.FeedbackButtons.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user