From 2d3e5f6a727677563a1c4d7124f07932ef1f7d96 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 12:54:24 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L5-4]=20=EC=95=B1=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=20(session)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppSettings.Models.cs (수정): - AppSession 클래스 신규: Name/Description/Apps/CreatedAt - SessionApp 클래스 신규: Path/Arguments/Label/SnapPosition/DelayMs - 스냅 위치 14종: full/left/right/tl~br/center/third-l/c/r/two3-l/r/none AppSettings.cs (수정): - [JsonPropertyName("appSessions")] AppSessions 리스트 추가 SessionHandler.cs (신규, ~160줄): - prefix="session", GetItemsAsync: 목록+필터, new/edit/del 서브커맨드 - ExecuteAsync: 편집기 열기 또는 LaunchSessionAsync 실행 - LaunchSessionAsync: Process.Start → 창 핸들 대기(최대 6초 폴링) → ApplySnapToWindow - ApplySnapToWindow: P/Invoke(SetWindowPos, ShowWindow, MonitorFromWindow, GetMonitorInfo) + 14종 스냅 좌표 계산 SessionEditorWindow.xaml (신규, ~200줄): - 타이틀바 + 세션명·설명 TextBox + 앱 목록 영역 + 하단 버튼 바 - SnapPickerPopup: PlacementTarget 코드에서 동적 지정, ContentControl에 StackPanel 14개 옵션 SessionEditorWindow.xaml.cs (신규, ~240줄): - AppRowUi 내부 모델: Path/Label/SnapPosition/Args/DelayMs + SnapLabelRef 갱신 - BuildRowGrid(): 경로TextBox + 라벨TextBox + 스냅Border(팝업) + 삭제Border - BtnAddApp: OpenFileDialog → 자동 라벨(파일명) - BtnSave: 유효 행 필터 → AppSession 구성 → 기존 교체 또는 신규 추가 → Save() App.xaml.cs (수정): - Phase L5-4 섹션에 SessionHandler 등록 docs/LAUNCHER_ROADMAP.md (수정): - L5-4 항목 ✅ 완료 표시 + 구현 상세 기록 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 2 +- src/AxCopilot/App.xaml.cs | 2 + src/AxCopilot/Handlers/SessionHandler.cs | 301 ++++++++++++++ src/AxCopilot/Models/AppSettings.Models.cs | 48 +++ src/AxCopilot/Models/AppSettings.cs | 6 + src/AxCopilot/Views/SessionEditorWindow.xaml | 251 ++++++++++++ .../Views/SessionEditorWindow.xaml.cs | 386 ++++++++++++++++++ 7 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Handlers/SessionHandler.cs create mode 100644 src/AxCopilot/Views/SessionEditorWindow.xaml create mode 100644 src/AxCopilot/Views/SessionEditorWindow.xaml.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 5c3ba9e..35736d4 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -132,7 +132,7 @@ | L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 | | L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 | | L5-3 | **QuickLook 인라인 편집** ✅ | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 | -| L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | +| L5-4 | **앱 세션 스냅** ✅ | `session` 프리픽스. `AppSession/SessionApp` 모델 추가 + `AppSettings.AppSessions` 저장. `SessionHandler`: 목록·실행·new/edit/del 서브커맨드. `SessionEditorWindow`: 세션 이름·설명·앱 행(경로+라벨+스냅 팝업 14종+삭제) 인라인 편집. 실행 시 Process.Start → 창 핸들 대기(6초) → ApplySnapToWindow(P/Invoke SetWindowPos+ShowWindow) | 중간 | | L5-5 | **배치 파일 이름 변경** ✅ | `batchren` 프리픽스로 BatchRenameWindow 오픈. 변수 패턴(`{name}`, `{n:3}`, `{date:format}`, `{ext}`) + 정규식 모드(`/old/new/`). 드래그 앤 드롭·폴더/파일 추가, DataGrid 실시간 미리보기, 충돌 감지(배경 붉은 강조), 확장자 유지 토글, 시작 번호 지정, 적용 후 엔트리 갱신 | 중간 | | L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index ddc1153..60f5a86 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -182,6 +182,8 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new HotkeyHandler(settings)); // Phase L5-2: OCR 화면 텍스트 추출 (prefix=ocr) commandResolver.RegisterHandler(new OcrHandler()); + // Phase L5-4: 앱 세션 스냅 (prefix=session) + commandResolver.RegisterHandler(new SessionHandler(settings)); // Phase L5-5: 배치 파일 이름변경 (prefix=batchren) commandResolver.RegisterHandler(new BatchRenameHandler()); diff --git a/src/AxCopilot/Handlers/SessionHandler.cs b/src/AxCopilot/Handlers/SessionHandler.cs new file mode 100644 index 0000000..a3298b4 --- /dev/null +++ b/src/AxCopilot/Handlers/SessionHandler.cs @@ -0,0 +1,301 @@ +using System.IO; +using System.Runtime.InteropServices; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다. +/// +/// 예: session → 저장된 세션 목록 +/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용) +/// session new 이름 → 새 세션 편집기 열기 +/// session edit 이름 → 기존 세션 편집기 열기 +/// session del 이름 → 세션 삭제 +/// +/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다. +/// +public class SessionHandler : IActionHandler +{ + private readonly SettingsService _settings; + + public SessionHandler(SettingsService settings) { _settings = settings; } + + public string? Prefix => "session"; + + public PluginMetadata Metadata => new( + "AppSession", + "앱 세션 스냅 — session", + "1.0", + "AX"); + + // ─── 항목 목록 ────────────────────────────────────────────────────────── + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries); + var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : ""; + + // "new [이름]" — 새 세션 만들기 + if (cmd == "new") + { + var name = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "새 세션"; + return Task.FromResult>(new[] + { + new LauncherItem($"'{name}' 세션 만들기", + "편집기에서 앱 목록과 스냅 레이아웃을 설정합니다", + null, $"__new__{name}", Symbol: "\uE710") + }); + } + + // "edit 이름" — 기존 세션 편집 + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 세션 편집", + "편집기 열기", + null, $"__edit__{parts[1]}", Symbol: "\uE70F") + }); + } + + // "del 이름" or "delete 이름" — 세션 삭제 + if ((cmd == "del" || cmd == "delete") && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 세션 삭제", + "Enter로 삭제 확인 (되돌릴 수 없습니다)", + null, $"__del__{parts[1]}", Symbol: Symbols.Delete) + }); + } + + // 세션 목록 (필터 적용) + var sessions = _settings.Settings.AppSessions; + var filter = q.ToLowerInvariant(); + var items = new List(); + + foreach (var s in sessions) + { + if (!string.IsNullOrEmpty(filter) && + !s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) && + !s.Description.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + + var appNames = string.Join(", ", + s.Apps.Take(3).Select(a => + string.IsNullOrEmpty(a.Label) + ? Path.GetFileNameWithoutExtension(a.Path) + : a.Label)); + + items.Add(new LauncherItem( + s.Name, + $"{s.Apps.Count}개 앱 · {appNames}", + null, s, + Symbol: "\uE8A1")); // 창 레이아웃 아이콘 + } + + if (items.Count == 0 && string.IsNullOrEmpty(filter)) + { + items.Add(new LauncherItem( + "저장된 세션 없음", + "'session new 이름'으로 앱 세션을 만드세요", + null, null, + Symbol: Symbols.Info)); + } + + // 새 세션 만들기 (항상 표시) + items.Add(new LauncherItem( + "새 세션 만들기", + "session new [이름] · 앱 목록 + 스냅 레이아웃 지정", + null, "__new__새 세션", + Symbol: "\uE710")); + + return Task.FromResult>(items); + } + + // ─── 실행 ───────────────────────────────────────────────────────────── + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is string s) + { + if (s.StartsWith("__new__")) + { + var name = s["__new__".Length..]; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var win = new Views.SessionEditorWindow(null, _settings); + win.InitialName = name; + win.Show(); + }); + return; + } + + if (s.StartsWith("__edit__")) + { + var name = s["__edit__".Length..]; + var session = _settings.Settings.AppSessions + .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var win = new Views.SessionEditorWindow(session, _settings); + win.Show(); + }); + return; + } + + if (s.StartsWith("__del__")) + { + var name = s["__del__".Length..]; + var session = _settings.Settings.AppSessions + .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (session != null) + { + _settings.Settings.AppSessions.Remove(session); + _settings.Save(); + NotificationService.Notify("AX Copilot", $"세션 '{name}' 삭제됨"); + } + return; + } + } + + if (item.Data is Models.AppSession appSession) + await LaunchSessionAsync(appSession, ct); + } + + // ─── 세션 실행 로직 ─────────────────────────────────────────────────── + private static async Task LaunchSessionAsync(Models.AppSession session, CancellationToken ct) + { + NotificationService.Notify("AX Copilot", $"'{session.Name}' 세션 시작..."); + LogService.Info($"세션 실행 시작: {session.Name} ({session.Apps.Count}개 앱)"); + + int launched = 0, failed = 0; + + foreach (var app in session.Apps) + { + ct.ThrowIfCancellationRequested(); + + if (app.DelayMs > 0) + await Task.Delay(app.DelayMs, ct); + + if (string.IsNullOrWhiteSpace(app.Path)) continue; + + System.Diagnostics.Process? proc = null; + try + { + proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = app.Path, + Arguments = app.Arguments ?? "", + UseShellExecute = true + }); + launched++; + } + catch (Exception ex) + { + LogService.Warn($"세션 앱 실행 실패: {app.Path} — {ex.Message}"); + failed++; + continue; + } + + // 스냅 위치 없거나 none → 그냥 실행 + if (proc == null || string.IsNullOrEmpty(app.SnapPosition) || app.SnapPosition == "none") + continue; + + // 창이 나타날 때까지 대기 (최대 6초) + var hWnd = IntPtr.Zero; + var deadline = DateTime.UtcNow.AddSeconds(6); + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + try { proc.Refresh(); } catch { break; } + hWnd = proc.MainWindowHandle; + if (hWnd != IntPtr.Zero) break; + await Task.Delay(200, ct); + } + + if (hWnd == IntPtr.Zero) + { + LogService.Warn($"세션: 창 핸들 획득 실패 ({app.Path})"); + continue; + } + + // 창이 완전히 렌더링될 시간 허용 + await Task.Delay(250, ct); + ApplySnapToWindow(hWnd, app.SnapPosition); + } + + var msg = failed > 0 + ? $"'{session.Name}' 실행 완료 ({launched}개 성공, {failed}개 실패)" + : $"'{session.Name}' 실행 완료 ({launched}개 앱)"; + NotificationService.Notify("AX Copilot", msg); + LogService.Info($"세션 실행 완료: {msg}"); + } + + // ─── 스냅 적용 (SnapHandler와 동일한 좌표 계산) ────────────────────── + private static void ApplySnapToWindow(IntPtr hwnd, string snapKey) + { + if (snapKey == "full") + { + ShowWindow(hwnd, SW_MAXIMIZE); + return; + } + + var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + var mi = new MONITORINFO { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(hMonitor, ref mi)) return; + + var w = mi.rcWork; + int mw = w.right - w.left; + int mh = w.bottom - w.top; + int mx = w.left; + int my = w.top; + + var (x, y, cw, ch) = snapKey switch + { + "left" => (mx, my, mw / 2, mh), + "right" => (mx + mw / 2, my, mw / 2, mh), + "top" => (mx, my, mw, mh / 2), + "bottom" => (mx, my + mh / 2, mw, mh / 2), + "tl" => (mx, my, mw / 2, mh / 2), + "tr" => (mx + mw / 2, my, mw / 2, mh / 2), + "bl" => (mx, my + mh / 2, mw / 2, mh / 2), + "br" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2), + "third-l" => (mx, my, mw / 3, mh), + "third-c" => (mx + mw / 3, my, mw / 3, mh), + "third-r" => (mx + mw * 2 / 3,my, mw / 3, mh), + "two3-l" => (mx, my, mw * 2 / 3, mh), + "two3-r" => (mx + mw / 3, my, mw * 2 / 3, mh), + "center" => (mx + mw / 10, my + mh / 10, mw * 8 / 10, mh * 8 / 10), + _ => (mx, my, mw, mh) + }; + + ShowWindow(hwnd, SW_RESTORE); + SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER); + } + + // ─── P/Invoke ───────────────────────────────────────────────────────── + private const uint SWP_SHOWWINDOW = 0x0040; + private const uint SWP_NOZORDER = 0x0004; + private const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + private const int SW_RESTORE = 9; + private const int SW_MAXIMIZE = 3; + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int left, top, right, bottom; } + + [StructLayout(LayoutKind.Sequential)] + private struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor, rcWork; + public uint dwFlags; + } + + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); + [DllImport("user32.dll")] private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); +} diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index 657e51b..919ea78 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -262,6 +262,54 @@ public class HotkeyAssignment public string Type { get; set; } = "app"; } +// ─── 앱 세션 스냅 ──────────────────────────────────────────────────────────────── + +/// +/// 앱 세션: 여러 앱을 지정 레이아웃으로 한번에 실행하는 세트. +/// session 핸들러로 저장/로드/실행합니다. +/// +public class AppSession +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("description")] + public string Description { get; set; } = ""; + + [JsonPropertyName("apps")] + public List Apps { get; set; } = new(); + + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +/// +/// 세션 내 개별 앱 항목. 실행 경로·인자·스냅 위치를 지정합니다. +/// +public class SessionApp +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("args")] + public string Arguments { get; set; } = ""; + + /// 표시 이름. 비어 있으면 파일명을 사용합니다. + [JsonPropertyName("label")] + public string Label { get; set; } = ""; + + /// + /// 스냅 위치. left / right / tl / tr / bl / br / full / center / + /// third-l / third-c / third-r / two3-l / two3-r / none + /// + [JsonPropertyName("snap")] + public string SnapPosition { get; set; } = "full"; + + /// 이 앱을 실행하기 전 대기 시간(ms). 기본값 0. + [JsonPropertyName("delayMs")] + public int DelayMs { get; set; } = 0; +} + // ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── public class ReminderSettings diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 4fc54f2..ddc2dca 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -112,6 +112,12 @@ public class AppSettings [JsonPropertyName("customHotkeys")] public List CustomHotkeys { get; set; } = new(); + /// + /// 앱 세션 목록. session 핸들러로 여러 앱을 지정 레이아웃에 한번에 실행합니다. + /// + [JsonPropertyName("appSessions")] + public List AppSessions { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); } diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml b/src/AxCopilot/Views/SessionEditorWindow.xaml new file mode 100644 index 0000000..60de7e2 --- /dev/null +++ b/src/AxCopilot/Views/SessionEditorWindow.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml.cs b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs new file mode 100644 index 0000000..29b3fdf --- /dev/null +++ b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs @@ -0,0 +1,386 @@ +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// L5-4: 앱 세션 편집기. +/// 세션 이름, 앱 목록(경로 + 라벨 + 스냅 위치)을 편집하여 저장합니다. +/// +public partial class SessionEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly AppSession? _original; // 편집 모드 원본 (새 세션이면 null) + private readonly List _rows = new(); + + // 스냅 팝업 대상 행 + private AppRowUi? _snapTargetRow; + + // 사용 가능한 스냅 위치 목록 (키 → 표시명) + private static readonly (string Key, string Label)[] SnapOptions = + [ + ("full", "전체화면"), + ("left", "왼쪽 절반"), + ("right", "오른쪽 절반"), + ("tl", "좌상단 1/4"), + ("tr", "우상단 1/4"), + ("bl", "좌하단 1/4"), + ("br", "우하단 1/4"), + ("center", "중앙 80%"), + ("third-l", "좌측 1/3"), + ("third-c", "중앙 1/3"), + ("third-r", "우측 1/3"), + ("two3-l", "좌측 2/3"), + ("two3-r", "우측 2/3"), + ("none", "스냅 없음"), + ]; + + // ────────────────────────────────────────────────────────────────────── + /// + /// 세션 편집기를 엽니다. + /// + /// 편집할 기존 세션. null이면 새로 만들기 모드. + /// 설정 서비스. + public SessionEditorWindow(AppSession? session, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _original = session; + + BuildSnapPopup(); + LoadSession(session); + } + + /// 새 세션 모드일 때 기본 이름을 설정합니다. + public string InitialName + { + set { if (_original == null) NameBox.Text = value; } + } + + // ─── 초기화 ─────────────────────────────────────────────────────────── + private void LoadSession(AppSession? session) + { + if (session == null) + { + NameBox.Text = "새 세션"; + DescBox.Text = ""; + } + else + { + NameBox.Text = session.Name; + DescBox.Text = session.Description; + foreach (var app in session.Apps) + AddRow(app.Path, app.Label, app.SnapPosition, app.Arguments, app.DelayMs); + } + + RefreshEmptyState(); + } + + private void BuildSnapPopup() + { + var panel = new StackPanel { Margin = new Thickness(0) }; + + foreach (var (key, label) in SnapOptions) + { + var keyCapture = key; + var border = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(10, 5, 10, 5), + Cursor = Cursors.Hand + }; + + border.MouseEnter += (_, _) => + border.Background = new SolidColorBrush(Color.FromArgb(0x28, 0xFF, 0xFF, 0xFF)); + border.MouseLeave += (_, _) => + border.Background = Brushes.Transparent; + + var stack = new StackPanel { Orientation = Orientation.Horizontal }; + stack.Children.Add(new TextBlock + { + Text = keyCapture, + FontFamily = new FontFamily("Cascadia Code, Consolas"), + FontSize = 11, + Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue, + VerticalAlignment = VerticalAlignment.Center, + MinWidth = 68, + }); + stack.Children.Add(new TextBlock + { + Text = label, + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }); + border.Child = stack; + + border.MouseLeftButtonUp += (_, _) => + { + if (_snapTargetRow != null) + { + _snapTargetRow.SnapPosition = keyCapture; + _snapTargetRow.UpdateSnapLabel(); + } + SnapPickerPopup.IsOpen = false; + }; + + panel.Children.Add(border); + } + + SnapOptionsList.Content = panel; + } + + // ─── 앱 행 추가 ─────────────────────────────────────────────────────── + private void AddRow(string path = "", string label = "", string snap = "full", + string args = "", int delayMs = 0) + { + var row = new AppRowUi(path, label, snap, args, delayMs); + _rows.Add(row); + + var rowGrid = BuildRowGrid(row); + AppListPanel.Children.Add(rowGrid); + RefreshEmptyState(); + } + + private Grid BuildRowGrid(AppRowUi row) + { + var grid = new Grid { Margin = new Thickness(14, 2, 4, 2) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(108) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(30) }); + + // 경로 TextBox + var pathBox = new TextBox + { + Text = row.Path, + FontSize = 11, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "앱 실행 파일 경로", + Margin = new Thickness(0, 0, 4, 0), + }; + pathBox.TextChanged += (_, _) => row.Path = pathBox.Text; + Grid.SetColumn(pathBox, 0); + + // 라벨 TextBox + var labelBox = new TextBox + { + Text = row.Label, + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "표시 이름 (선택)", + Margin = new Thickness(0, 0, 4, 0), + }; + labelBox.TextChanged += (_, _) => row.Label = labelBox.Text; + Grid.SetColumn(labelBox, 1); + + // 스냅 선택 Border (클릭 시 팝업) + var snapBtn = new Border + { + CornerRadius = new CornerRadius(4), + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + Margin = new Thickness(0, 0, 4, 0), + ToolTip = "스냅 위치 선택", + }; + snapBtn.MouseEnter += (_, _) => + snapBtn.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + snapBtn.MouseLeave += (_, _) => + snapBtn.Background = Brushes.Transparent; + + var snapLabel = new TextBlock + { + FontFamily = new FontFamily("Cascadia Code, Consolas"), + FontSize = 10, + Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + snapBtn.Child = snapLabel; + + // AppRowUi가 라벨 TextBlock을 참조할 수 있도록 저장 + row.SnapLabelRef = snapLabel; + row.SnapButtonRef = snapBtn; + row.UpdateSnapLabel(); + + snapBtn.MouseLeftButtonUp += (sender, e) => + { + _snapTargetRow = row; + SnapPickerPopup.PlacementTarget = (FrameworkElement)sender; + SnapPickerPopup.IsOpen = true; + e.Handled = true; + }; + Grid.SetColumn(snapBtn, 2); + + // 삭제 버튼 + var delBtn = new Border + { + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(4), + Cursor = Cursors.Hand, + ToolTip = "이 앱 제거", + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + }; + delBtn.MouseEnter += (_, _) => + delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50)); + delBtn.MouseLeave += (_, _) => + delBtn.Background = Brushes.Transparent; + + var delIcon = new TextBlock + { + Text = "\uE74D", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + delBtn.Child = delIcon; + + delBtn.MouseLeftButtonUp += (_, _) => + { + _rows.Remove(row); + AppListPanel.Children.Remove(grid); + RefreshEmptyState(); + }; + Grid.SetColumn(delBtn, 3); + + grid.Children.Add(pathBox); + grid.Children.Add(labelBox); + grid.Children.Add(snapBtn); + grid.Children.Add(delBtn); + + return grid; + } + + private void RefreshEmptyState() + { + EmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + // ─── 이벤트 핸들러 ──────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); + + private void BtnAddApp_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.OpenFileDialog + { + Title = "앱 실행 파일 선택", + Filter = "실행 파일 (*.exe)|*.exe|모든 파일 (*.*)|*.*", + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var label = Path.GetFileNameWithoutExtension(dlg.FileName); + AddRow(dlg.FileName, label, "full"); + } + + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + NotificationService.Notify("세션 편집기", "세션 이름을 입력하세요."); + NameBox.Focus(); + return; + } + + // 빈 경로 행 필터링 + var validApps = _rows + .Where(r => !string.IsNullOrWhiteSpace(r.Path)) + .Select(r => new SessionApp + { + Path = r.Path.Trim(), + Arguments = r.Arguments.Trim(), + Label = r.Label.Trim(), + SnapPosition = r.SnapPosition, + DelayMs = r.DelayMs, + }).ToList(); + + var session = new AppSession + { + Name = name, + Description = DescBox.Text.Trim(), + Apps = validApps, + CreatedAt = _original?.CreatedAt ?? DateTime.Now, + }; + + // 기존 세션 교체 또는 신규 추가 + if (_original != null) + { + var idx = _settings.Settings.AppSessions.IndexOf(_original); + if (idx >= 0) + _settings.Settings.AppSessions[idx] = session; + else + _settings.Settings.AppSessions.Add(session); + } + else + { + // 동일 이름 세션이 있으면 교체 + var existing = _settings.Settings.AppSessions + .FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + _settings.Settings.AppSessions.Remove(existing); + _settings.Settings.AppSessions.Add(session); + } + + _settings.Save(); + NotificationService.Notify("AX Copilot", + $"세션 '{name}' 저장됨 ({validApps.Count}개 앱)"); + LogService.Info($"세션 저장: {name} ({validApps.Count}개 앱)"); + Close(); + } +} + +// ─── 앱 행 UI 모델 ──────────────────────────────────────────────────────────── +internal class AppRowUi +{ + public string Path { get; set; } + public string Label { get; set; } + public string SnapPosition { get; set; } + public string Arguments { get; set; } + public int DelayMs { get; set; } + + // UI 참조 (라벨 갱신용) + internal System.Windows.Controls.TextBlock? SnapLabelRef { get; set; } + internal System.Windows.Controls.Border? SnapButtonRef { get; set; } + + public AppRowUi(string path, string label, string snap, string args, int delayMs) + { + Path = path; + Label = label; + SnapPosition = snap; + Arguments = args; + DelayMs = delayMs; + } + + public void UpdateSnapLabel() + { + if (SnapLabelRef != null) + SnapLabelRef.Text = SnapPosition; + } +}