AX Agent 공통 팝업과 시각 상호작용 렌더를 분리해 메인 창 구조를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled

- 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을 확인했다.
This commit is contained in:
2026-04-06 09:02:41 +09:00
parent ccaa24745e
commit 4c1513a5da
5 changed files with 425 additions and 382 deletions

View File

@@ -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,
});
}
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
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
}
}
/// <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 = 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()