From 4c1513a5da12f3754014a00f51c48aed31401d82 Mon Sep 17 00:00:00 2001 From: lacvet Date: Mon, 6 Apr 2026 09:02:41 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EA=B3=B5=ED=86=B5=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=EA=B3=BC=20=EC=8B=9C=EA=B0=81=20=EC=83=81=ED=98=B8?= =?UTF-8?q?=EC=9E=91=EC=9A=A9=20=EB=A0=8C=EB=8D=94=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=B4=20=EB=A9=94=EC=9D=B8=20=EC=B0=BD=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatWindow.VisualInteractionHelpers.cs를 추가해 메시지 액션 버튼, 선택 스타일, hover 애니메이션, 공통 체크 아이콘 생성을 메인 창 코드 밖으로 이동했다. - ChatWindow.PopupPresentation.cs를 추가해 공통 테마 팝업 컨테이너, 메뉴 아이템, 구분선, 최근 폴더 컨텍스트 메뉴 구성을 한 곳으로 모았다. - README와 DEVELOPMENT 문서에 2026-04-06 09:03 (KST) 기준 구조 분리 이력을 반영했다. - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 검증 결과 경고 0 / 오류 0을 확인했다. --- README.md | 4 + docs/DEVELOPMENT.md | 3 + .../Views/ChatWindow.PopupPresentation.cs | 159 ++++++++ .../ChatWindow.VisualInteractionHelpers.cs | 259 ++++++++++++ src/AxCopilot/Views/ChatWindow.xaml.cs | 382 ------------------ 5 files changed, 425 insertions(+), 382 deletions(-) create mode 100644 src/AxCopilot/Views/ChatWindow.PopupPresentation.cs create mode 100644 src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs diff --git a/README.md b/README.md index 10ff94b..c95f90a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-06 09:03 (KST) +- AX Agent 공통 선택 팝업 조립 로직을 `ChatWindow.PopupPresentation.cs`로 분리했습니다. 테마 팝업 컨테이너, 공통 메뉴 아이템, 구분선, 최근 폴더 우클릭 컨텍스트 메뉴가 메인 창 코드 밖으로 이동해 footer/file-browser 쪽 팝업 품질 작업을 이어가기 쉬운 구조로 정리했습니다. +- `ChatWindow.xaml.cs`는 대화 상태와 런타임 orchestration 쪽에 더 집중하도록 정리했고, 공통 팝업 시각 언어를 한 곳에서 다듬을 수 있는 기반을 만들었습니다. + - 업데이트: 2026-04-06 08:55 (KST) - AX Agent 파일 브라우저 렌더를 `ChatWindow.FileBrowserPresentation.cs`로 분리했습니다. 파일 탐색기 열기/닫기, 폴더 트리 구성, 파일 헤더/아이콘/크기 표시, 우클릭 메뉴, 디바운스 새로고침 흐름이 메인 창 코드 밖으로 이동했습니다. - `ChatWindow.xaml.cs`는 transcript·runtime orchestration 중심으로 더 정리됐고, claw-code 기준 사이드 surface 품질 작업을 이어가기 쉬운 구조로 맞췄습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6162af7..984273e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,8 @@ # AX Copilot - 媛쒕컻 臾몄꽌 +- Document update: 2026-04-06 09:03 (KST) - Split common themed popup construction out of `ChatWindow.xaml.cs` into `ChatWindow.PopupPresentation.cs`. Shared popup container creation, generic popup menu items/separators, and the recent-folder context menu now live in a dedicated partial instead of the main window orchestration file. +- Document update: 2026-04-06 09:03 (KST) - This keeps footer/file-browser popup styling on a single visual path and reduces direct popup composition inside the main chat window flow, making further `claw-code` style popup UX work easier to maintain. + - Document update: 2026-04-06 07:31 (KST) - Split permission presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.PermissionPresentation.cs`. Permission popup row construction, popup refresh, section expansion persistence, and permission banner/status styling now live in a dedicated partial instead of the main window orchestration file. - Document update: 2026-04-06 07:31 (KST) - Split context usage card/popup rendering into `ChatWindow.ContextUsagePresentation.cs`. The Cowork/Code context usage ring, tooltip popup copy, hover close behavior, and screen-coordinate hit testing are now isolated from the rest of the chat window flow. - Document update: 2026-04-06 01:37 (KST) - Reworked AX Agent plan approval toward a more transcript-native flow. The inline decision card remains the primary approval path, while the `계획` affordance now opens the stored plan as a detail-only surface instead of acting like a required popup step. diff --git a/src/AxCopilot/Views/ChatWindow.PopupPresentation.cs b/src/AxCopilot/Views/ChatWindow.PopupPresentation.cs new file mode 100644 index 0000000..f15fcd5 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.PopupPresentation.cs @@ -0,0 +1,159 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private Popup? _sharedContextPopup; + + private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu( + UIElement? placementTarget = null, + PlacementMode placement = PlacementMode.MousePoint, + double minWidth = 200) + { + _sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + + var bg = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var panel = new StackPanel { Margin = new Thickness(2) }; + var container = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(6), + MinWidth = minWidth, + Child = panel, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 3, + Opacity = 0.18, + Color = Colors.Black, + Direction = 270, + }, + }; + + var popup = new Popup + { + Child = container, + StaysOpen = false, + AllowsTransparency = true, + PopupAnimation = PopupAnimation.Fade, + Placement = placement, + PlacementTarget = placementTarget, + }; + + _sharedContextPopup = popup; + return (popup, panel); + } + + private Border CreatePopupMenuItem( + Popup popup, + string icon, + string label, + Brush iconBrush, + Brush labelBrush, + Brush hoverBrush, + Action action) + { + var sp = new StackPanel { Orientation = Orientation.Horizontal }; + sp.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12.5, + Foreground = iconBrush, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 9, 0), + }); + sp.Children.Add(new TextBlock + { + Text = label, + FontSize = 12.5, + Foreground = labelBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + + var item = new Border + { + Child = sp, + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(8), + Cursor = Cursors.Hand, + Padding = new Thickness(10, 7, 12, 7), + Margin = new Thickness(0, 1, 0, 1), + }; + item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; }; + item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; + item.MouseLeftButtonUp += (_, _) => + { + popup.SetCurrentValue(Popup.IsOpenProperty, false); + action(); + }; + + return item; + } + + private static void AddPopupMenuSeparator(Panel panel, Brush brush) + { + panel.Children.Add(new Border + { + Height = 1, + Margin = new Thickness(10, 4, 10, 4), + Background = brush, + Opacity = 0.35, + }); + } + + /// 최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다. + private void ShowRecentFolderContextMenu(string folderPath) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; + var warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); + var (popup, panel) = CreateThemedPopupMenu(); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = folderPath, + UseShellExecute = true, + }); + } + catch + { + } + })); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () => + { + try { Clipboard.SetText(folderPath); } catch { } + })); + + AddPopupMenuSeparator(panel, borderBrush); + + panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () => + { + _settings.Settings.Llm.RecentWorkFolders.RemoveAll( + p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase)); + _settings.Save(); + if (FolderMenuPopup.IsOpen) + ShowFolderMenu(); + })); + + Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs b/src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs new file mode 100644 index 0000000..37e4a8d --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.VisualInteractionHelpers.cs @@ -0,0 +1,259 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + /// 마우스 오버 시 살짝 확대하는 호버 애니메이션. 독립적 공간이 있는 버튼에만 적용합니다. + private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08) + { + void EnsureTransform() + { + element.RenderTransformOrigin = new Point(0.5, 0.5); + 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); + }; + } + + /// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션. + 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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var icon = new TextBlock + { + Text = symbol, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center + }; + + var btn = new Button + { + Content = icon, + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Cursor = Cursors.Hand, + Width = 24, + Height = 24, + Padding = new Thickness(0), + Margin = new Thickness(0, 0, 2, 0), + ToolTip = tooltip + }; + + btn.Template = BuildMinimalIconButtonTemplate(); + btn.MouseEnter += (_, _) => + { + icon.Foreground = hoverBrush; + btn.Background = hoverBg; + }; + btn.MouseLeave += (_, _) => + { + icon.Foreground = foreground; + btn.Background = Brushes.Transparent; + }; + btn.Click += (_, _) => onClick(); + return btn; + } + + private void ShowMessageActionBar(StackPanel actionBar) + { + if (actionBar == null) + return; + + actionBar.Opacity = 1; + } + + private void HideMessageActionBarIfNotSelected(StackPanel actionBar) + { + if (actionBar == null) + return; + + if (!ReferenceEquals(_selectedMessageActionBar, actionBar)) + actionBar.Opacity = 0; + } + + private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null) + { + if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar)) + _selectedMessageActionBar.Opacity = 0; + + if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder)) + ApplyMessageSelectionStyle(_selectedMessageBorder, false); + + _selectedMessageActionBar = actionBar; + _selectedMessageActionBar.Opacity = 1; + _selectedMessageBorder = messageBorder; + if (_selectedMessageBorder != null) + ApplyMessageSelectionStyle(_selectedMessageBorder, true); + } + + private void ApplyMessageSelectionStyle(Border border, bool selected) + { + if (border == null) + return; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + border.BorderBrush = selected ? accent : defaultBorder; + border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1); + border.Effect = selected + ? new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 0, + Opacity = 0.10, + Color = Colors.Black, + } + : null; + } + + private static ControlTemplate BuildMinimalIconButtonTemplate() + { + var template = new ControlTemplate(typeof(Button)); + var border = new FrameworkElementFactory(typeof(Border)); + border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); + border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty)); + border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); + border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); + var presenter = new FrameworkElementFactory(typeof(ContentPresenter)); + presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + border.AppendChild(presenter); + template.VisualTree = border; + return template; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 9bafc11..66ae540 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1608,152 +1608,6 @@ public partial class ChatWindow : Window SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd); } - private Popup? _sharedContextPopup; - - private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu( - UIElement? placementTarget = null, - PlacementMode placement = PlacementMode.MousePoint, - double minWidth = 200) - { - _sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - - var bg = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var panel = new StackPanel { Margin = new Thickness(2) }; - var container = new Border - { - Background = bg, - BorderBrush = border, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(10), - Padding = new Thickness(6), - MinWidth = minWidth, - Child = panel, - Effect = new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 16, - ShadowDepth = 3, - Opacity = 0.18, - Color = Colors.Black, - Direction = 270, - }, - }; - - var popup = new Popup - { - Child = container, - StaysOpen = false, - AllowsTransparency = true, - PopupAnimation = PopupAnimation.Fade, - Placement = placement, - PlacementTarget = placementTarget, - }; - - _sharedContextPopup = popup; - return (popup, panel); - } - - private Border CreatePopupMenuItem( - Popup popup, - string icon, - string label, - Brush iconBrush, - Brush labelBrush, - Brush hoverBrush, - Action action) - { - var sp = new StackPanel { Orientation = Orientation.Horizontal }; - sp.Children.Add(new TextBlock - { - Text = icon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 12.5, - Foreground = iconBrush, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 9, 0), - }); - sp.Children.Add(new TextBlock - { - Text = label, - FontSize = 12.5, - Foreground = labelBrush, - VerticalAlignment = VerticalAlignment.Center, - }); - - var item = new Border - { - Child = sp, - Background = Brushes.Transparent, - CornerRadius = new CornerRadius(8), - Cursor = Cursors.Hand, - Padding = new Thickness(10, 7, 12, 7), - Margin = new Thickness(0, 1, 0, 1), - }; - item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; }; - item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; - item.MouseLeftButtonUp += (_, _) => - { - popup.SetCurrentValue(Popup.IsOpenProperty, false); - action(); - }; - - return item; - } - - private static void AddPopupMenuSeparator(Panel panel, Brush brush) - { - panel.Children.Add(new Border - { - Height = 1, - Margin = new Thickness(10, 4, 10, 4), - Background = brush, - Opacity = 0.35, - }); - } - - /// 최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다. - private void ShowRecentFolderContextMenu(string folderPath) - { - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; - var warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)); - var (popup, panel) = CreateThemedPopupMenu(); - - panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () => - { - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = folderPath, - UseShellExecute = true, - }); - } - catch { } - })); - - panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () => - { - try { Clipboard.SetText(folderPath); } catch { } - })); - - AddPopupMenuSeparator(panel, borderBrush); - - panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () => - { - _settings.Settings.Llm.RecentWorkFolders.RemoveAll( - p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase)); - _settings.Save(); - // 메뉴 새로고침 - if (FolderMenuPopup.IsOpen) - ShowFolderMenu(); - })); - - Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input); - } - private void BtnFolderClear_Click(object sender, RoutedEventArgs e) { FolderPathLabel.Text = "폴더를 선택하세요"; @@ -4014,242 +3868,6 @@ public partial class ChatWindow : Window } } - /// 마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다. - /// - /// 마우스 오버 시 살짝 확대하는 호버 애니메이션. - /// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다. - /// 독립적 공간이 있는 버튼에만 적용하세요. - /// - 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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var hoverBg = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)); - var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - var icon = new TextBlock - { - Text = symbol, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 10, - Foreground = foreground, - VerticalAlignment = VerticalAlignment.Center - }; - var btn = new Button - { - Content = icon, - Background = Brushes.Transparent, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - Cursor = Cursors.Hand, - Width = 24, - Height = 24, - Padding = new Thickness(0), - Margin = new Thickness(0, 0, 2, 0), - ToolTip = tooltip - }; - btn.Template = BuildMinimalIconButtonTemplate(); - btn.MouseEnter += (_, _) => - { - icon.Foreground = hoverBrush; - btn.Background = hoverBg; - }; - btn.MouseLeave += (_, _) => - { - icon.Foreground = foreground; - btn.Background = Brushes.Transparent; - }; - btn.Click += (_, _) => onClick(); - return btn; - } - - private void ShowMessageActionBar(StackPanel actionBar) - { - if (actionBar == null) - return; - - actionBar.Opacity = 1; - } - - private void HideMessageActionBarIfNotSelected(StackPanel actionBar) - { - if (actionBar == null) - return; - - if (!ReferenceEquals(_selectedMessageActionBar, actionBar)) - actionBar.Opacity = 0; - } - - private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null) - { - if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar)) - _selectedMessageActionBar.Opacity = 0; - - if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder)) - ApplyMessageSelectionStyle(_selectedMessageBorder, false); - - _selectedMessageActionBar = actionBar; - _selectedMessageActionBar.Opacity = 1; - _selectedMessageBorder = messageBorder; - if (_selectedMessageBorder != null) - ApplyMessageSelectionStyle(_selectedMessageBorder, true); - } - - private void ApplyMessageSelectionStyle(Border border, bool selected) - { - if (border == null) - return; - - var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; - border.BorderBrush = selected ? accent : defaultBorder; - border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1); - border.Effect = selected - ? new System.Windows.Media.Effects.DropShadowEffect - { - BlurRadius = 16, - ShadowDepth = 0, - Opacity = 0.10, - Color = Colors.Black, - } - : null; - } - - private static ControlTemplate BuildMinimalIconButtonTemplate() - { - var template = new ControlTemplate(typeof(Button)); - var border = new FrameworkElementFactory(typeof(Border)); - border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); - border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty)); - border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); - border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); - border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); - var presenter = new FrameworkElementFactory(typeof(ContentPresenter)); - presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); - presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); - border.AppendChild(presenter); - template.VisualTree = border; - return template; - } - - // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── private void StopAiIconPulse()