[Phase L6] 런처 워크플로우 자동화 확장 완료

MacroEntry/MacroStep 모델 (AppSettings.Models.cs):
- MacroEntry: Id, Name, Description, Steps, CreatedAt
- MacroStep: Type(app/url/folder/notification/cmd), Target, Args, Label, DelayMs
- AppSettings.Macros List<MacroEntry> 추가

Handlers/MacroHandler.cs (170줄 신규):
- prefix=macro, 서브커맨드: new/edit/del/play
- 목록: 단계 미리보기(최대 3단계) 표시
- 재생: Process.Start + 알림 + PowerShell 순서 실행

Views/MacroEditorWindow.xaml (135줄 신규):
- 이름/설명 입력, 열 헤더(유형/대상/표시이름/딜레이)
- 동적 행 ScrollViewer + 하단 단계 추가/저장 버튼

Views/MacroEditorWindow.xaml.cs (230줄 신규):
- StepRowUi 내부 클래스: Grid+TypeButton+TargetBox+LabelBox+DelayBox
- 공유 Popup 타입 선택기(5종): PlacementTarget 동적 설정
- BtnSave: 빈 Target 행 필터링 후 MacroEntry 저장

Handlers/ContextHandler.cs (185줄 신규):
- prefix=ctx, GetForegroundWindow P/Invoke 프로세스 감지
- 5개 컨텍스트 규칙(웹/코드/오피스/탐색기/커뮤니케이션) → 상황별 제안
- Enter 시 Views.LauncherWindow.SetInputText(prefix) 호출

ScheduleEntry 조건 필드 (AppSettings.Models.cs):
- ConditionProcess: 프로세스 이름 (비어있으면 조건 없음)
- ConditionProcessMustRun: true=실행중, false=비실행중

Services/SchedulerService.cs:
- ShouldFire() 확장: Process.GetProcessesByName() 조건 체크

Views/ScheduleEditorWindow.xaml / .cs:
- 조건 섹션 UI: 프로세스명 TextBox + 실행중/비실행중 세그먼트
- LoadFromEntry/BtnSave_Click 조건 필드 연동

App.xaml.cs:
- Phase L6-2 MacroHandler(settings) 등록
- Phase L6-3 ContextHandler() 등록

docs/LAUNCHER_ROADMAP.md:
- Phase L6 섹션 추가 (L6-1~4 전체 )

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 13:24:41 +09:00
parent e92800165d
commit dab633edd5
11 changed files with 1101 additions and 4 deletions

View File

@@ -142,6 +142,19 @@
L5-1 (핫키) → L5-2 (OCR) → L5-3 (편집) → L5-5 (배치 이름변경) → L5-4 (세션 스냅) → L5-6 (스케줄러)
```
---
## Phase L6 — 워크플로우 자동화 확장 (v1.7.5~) ✅ 완료
> **방향**: 런처를 "행동 허브"로 완성. 매크로 시퀀서, 컨텍스트 인식, 조건부 스케줄로 반복 작업을 완전 자동화.
| # | 기능 | 설명 | 우선순위 |
|---|------|------|----------|
| L6-1 | **클립보드 파이프라인** ✅ | `pipe` 프리픽스(기존 구현). upper/lower/trim/sort/unique/b64e/b64d/urle/urld 등 체이닝 | 기존 |
| L6-2 | **런처 매크로 시퀀서** ✅ | `macro` 프리픽스. `MacroEntry/MacroStep` 모델 + `AppSettings.Macros` 저장. `MacroHandler`: 목록·new·edit·del·Enter 실행. `MacroEditorWindow`: 유형(앱/URL/폴더/알림/PS) + 대상 + 표시이름 + 딜레이(ms) 인라인 행 편집. 재생: 순서대로 Process.Start/알림 실행 | 중간 |
| L6-3 | **컨텍스트 감지 자동완성** ✅ | `ctx` 프리픽스. `GetForegroundWindow` P/Invoke로 현재 앱 감지. 5개 컨텍스트(웹 브라우저/코드 편집기/오피스/파일 탐색기/커뮤니케이션) → 상황별 런처 명령 제안. Enter 시 해당 프리픽스 런처 입력창에 자동 삽입 | 중간 |
| L6-4 | **조건부 스케줄** ✅ | `ScheduleEntry.ConditionProcess`(프로세스명) + `ConditionProcessMustRun`(실행중/비실행중) 필드 추가. `SchedulerService.ShouldFire()` 확장: `Process.GetProcessesByName()` 조건 체크. `ScheduleEditorWindow`에 조건 섹션 UI 추가 | 낮음 |
### L5-1 항목별 전용 핫키 — 구현 설계
```csharp

View File

@@ -192,6 +192,10 @@ public partial class App : System.Windows.Application
schedulerService.Start();
_schedulerService = schedulerService;
commandResolver.RegisterHandler(new ScheduleHandler(settings));
// Phase L6-2: 런처 매크로 (prefix=macro)
commandResolver.RegisterHandler(new MacroHandler(settings));
// Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx)
commandResolver.RegisterHandler(new ContextHandler());
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);

View File

@@ -0,0 +1,193 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다.
///
/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다.
/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천
/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천
/// </summary>
public class ContextHandler : IActionHandler
{
public string? Prefix => "ctx";
public PluginMetadata Metadata => new(
"Context",
"컨텍스트 명령 제안 — ctx",
"1.0",
"AX");
// ─── P/Invoke ─────────────────────────────────────────────────────────
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint dwProcessId);
// ─── 컨텍스트 규칙 ─────────────────────────────────────────────────────
// 프로세스 이름 → (앱 표시명, 추천 명령 목록)
private static readonly Dictionary<string[], (string AppName, ContextSuggestion[] Suggestions)> ContextMap
= new(new StringArrayEqualityComparer())
{
{
new[] { "chrome", "msedge", "firefox", "brave", "opera" },
("웹 브라우저", new[]
{
new ContextSuggestion("북마크 검색", "북마크를 검색합니다", "bm", Symbols.Favorite),
new ContextSuggestion("웹 검색", "기본 검색엔진으로 검색합니다", "?", "\uE721"),
new ContextSuggestion("URL 열기", "URL을 런처에서 직접 실행합니다", "url", "\uE71B"),
new ContextSuggestion("클립보드 내용 검색", "클립보드 이력을 검색합니다", "#", "\uE8C8"),
})
},
{
new[] { "code", "devenv", "rider", "idea64", "pycharm64", "webstorm64", "clion64" },
("코드 편집기", new[]
{
new ContextSuggestion("파일 검색", "인덱싱된 파일을 빠르게 엽니다", "", "\uE8A5"),
new ContextSuggestion("스니펫 입력", "텍스트 스니펫을 확장합니다", ";", "\uE8D2"),
new ContextSuggestion("클립보드 이력", "복사한 내용을 검색합니다", "#", "\uE8C8"),
new ContextSuggestion("파일 미리보기", "F3으로 파일 내용을 미리봅니다", "", "\uE7C3"),
new ContextSuggestion("QuickLook 편집","파일 인라인 편집 (Ctrl+E)", "", "\uE70F"),
})
},
{
new[] { "excel", "powerpnt", "winword", "onenote", "outlook", "hwp", "hwpx" },
("오피스", new[]
{
new ContextSuggestion("클립보드 이력", "복사한 셀·텍스트를 재사용합니다", "#", "\uE8C8"),
new ContextSuggestion("계산기", "수식을 빠르게 계산합니다", "=", "\uE8EF"),
new ContextSuggestion("날짜 계산", "날짜·기간을 계산합니다", "=today", "\uE787"),
new ContextSuggestion("파일 검색", "문서 파일을 빠르게 찾습니다", "", "\uE8A5"),
new ContextSuggestion("스니펫", "자주 쓰는 문구를 입력합니다", ";", "\uE8D2"),
})
},
{
new[] { "explorer" },
("파일 탐색기", new[]
{
new ContextSuggestion("파일 태그 검색", "태그로 파일을 찾습니다", "tag", "\uE932"),
new ContextSuggestion("배치 이름변경", "여러 파일을 한번에 이름변경합니다", "batchren", "\uE8AC"),
new ContextSuggestion("파일 미리보기", "선택 파일을 미리봅니다 (F3)", "", "\uE7C3"),
new ContextSuggestion("폴더 즐겨찾기", "즐겨찾기 폴더를 엽니다", "fav", Symbols.Favorite),
})
},
{
new[] { "slack", "teams", "zoom", "msteams" },
("커뮤니케이션", new[]
{
new ContextSuggestion("클립보드 이력", "공유할 내용을 클립보드에서 선택", "#", "\uE8C8"),
new ContextSuggestion("스크린 캡처", "화면을 캡처해 공유합니다", "cap", "\uE722"),
new ContextSuggestion("스니펫", "자주 쓰는 답변 텍스트를 입력합니다",";", "\uE8D2"),
new ContextSuggestion("번역", "텍스트를 번역합니다", "! 번역", "\uF2B7"),
})
},
};
// ─── 기본 제안 (앱 미인식) ────────────────────────────────────────────
private static readonly ContextSuggestion[] DefaultSuggestions =
{
new("파일/앱 검색", "이름으로 파일·앱을 빠르게 검색", "", "\uE721"),
new("클립보드 이력", "최근 복사한 내용 검색", "#", "\uE8C8"),
new("계산기", "수식 계산", "=", "\uE8EF"),
new("스니펫", "텍스트 스니펫 확장", ";", "\uE8D2"),
new("스케줄러", "자동화 스케줄 목록", "sched","\uE916"),
};
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var (procName, appDisplayName, suggestions) = GetContextInfo();
var items = new List<LauncherItem>();
var headerSub = string.IsNullOrEmpty(procName)
? "현재 포그라운드 앱을 인식할 수 없습니다"
: $"현재 앱: {appDisplayName} ({procName}) · 상황별 명령 제안";
items.Add(new LauncherItem(
"컨텍스트 제안",
headerSub,
null, null,
Symbol: "\uE945"));
var q = query.Trim().ToLowerInvariant();
foreach (var s in suggestions)
{
if (!string.IsNullOrEmpty(q) &&
!s.Title.Contains(q, StringComparison.OrdinalIgnoreCase) &&
!s.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase))
continue;
var subtitle = string.IsNullOrEmpty(s.Prefix)
? s.Subtitle
: $"{s.Subtitle} · 프리픽스: [{s.Prefix}]";
items.Add(new LauncherItem(
s.Title, subtitle, null, s.Prefix, Symbol: s.Symbol));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 제안 항목 실행 → 런처 입력창에 프리픽스 삽입
// LauncherWindow가 SetInputText를 지원하므로 Application 수준에서 접근
if (item.Data is string prefix && !string.IsNullOrEmpty(prefix))
{
var launcher = System.Windows.Application.Current?.Windows
.OfType<Views.LauncherWindow>()
.FirstOrDefault();
launcher?.SetInputText(prefix + " ");
}
return Task.CompletedTask;
}
// ─── 내부 유틸 ────────────────────────────────────────────────────────
private static (string ProcName, string AppName, ContextSuggestion[] Suggestions) GetContextInfo()
{
try
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return ("", "", DefaultSuggestions);
GetWindowThreadProcessId(hwnd, out var pid);
if (pid == 0) return ("", "", DefaultSuggestions);
var proc = Process.GetProcessById((int)pid);
var pName = proc.ProcessName.ToLowerInvariant();
foreach (var kv in ContextMap)
{
if (kv.Key.Any(k => pName.Contains(k)))
return (pName, kv.Value.AppName, kv.Value.Suggestions);
}
return (pName, pName, DefaultSuggestions);
}
catch
{
return ("", "", DefaultSuggestions);
}
}
// ─── 제안 레코드 ─────────────────────────────────────────────────────
private record ContextSuggestion(string Title, string Subtitle, string Prefix, string Symbol);
// ─── 키 비교기 ───────────────────────────────────────────────────────
private class StringArrayEqualityComparer : IEqualityComparer<string[]>
{
public bool Equals(string[]? x, string[]? y) =>
x != null && y != null && x.SequenceEqual(y);
public int GetHashCode(string[] obj) =>
obj.Aggregate(17, (h, s) => h * 31 + s.GetHashCode());
}
}

View File

@@ -0,0 +1,231 @@
using System.Diagnostics;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다.
///
/// 예: macro → 매크로 목록
/// macro 이름 → 이름으로 필터
/// macro new → 새 매크로 편집기 열기
/// macro edit 이름 → 기존 매크로 편집
/// macro del 이름 → 매크로 삭제
/// macro play 이름 → 즉시 실행
/// Enter로 선택한 매크로를 실행합니다.
/// </summary>
public class MacroHandler : IActionHandler
{
private readonly SettingsService _settings;
public MacroHandler(SettingsService settings) { _settings = settings; }
public string? Prefix => "macro";
public PluginMetadata Metadata => new(
"Macro",
"런처 매크로 — macro",
"1.0",
"AX");
// ─── 항목 목록 ──────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
if (cmd == "new")
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("새 매크로 만들기",
"편집기에서 단계별 실행 시퀀스를 설정합니다",
null, "__new__", Symbol: "\uE710")
});
}
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기",
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
});
}
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"'{parts[1]}' 매크로 삭제",
"Enter로 삭제 확인",
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
});
}
if (cmd == "play" && parts.Length > 1)
{
var entry = _settings.Settings.Macros
.FirstOrDefault(m => m.Name.Equals(parts[1], StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"[{entry.Name}] 매크로 실행",
$"{entry.Steps.Count}단계 · Enter로 즉시 실행",
null, entry, Symbol: "\uE768")
});
}
}
// 목록
var macros = _settings.Settings.Macros;
var filter = q.ToLowerInvariant();
var items = new List<LauncherItem>();
foreach (var m in macros)
{
if (!string.IsNullOrEmpty(filter) &&
!m.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!m.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
var preview = m.Steps.Count == 0
? "단계 없음"
: string.Join(" → ", m.Steps.Take(3).Select(s => string.IsNullOrWhiteSpace(s.Label) ? s.Target : s.Label))
+ (m.Steps.Count > 3 ? $" … +{m.Steps.Count - 3}" : "");
items.Add(new LauncherItem(
m.Name,
$"{m.Steps.Count}단계 · {preview}",
null, m, Symbol: "\uE768"));
}
if (items.Count == 0 && string.IsNullOrEmpty(filter))
{
items.Add(new LauncherItem(
"등록된 매크로 없음",
"'macro new'로 명령 시퀀스를 추가하세요",
null, null, Symbol: Symbols.Info));
}
items.Add(new LauncherItem(
"새 매크로 만들기",
"macro new · 앱·URL·폴더·알림을 순서대로 실행",
null, "__new__", Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 실행 ─────────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string s)
{
if (s == "__new__")
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(null, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__edit__"))
{
var name = s["__edit__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var win = new Views.MacroEditorWindow(entry, _settings);
win.Show();
});
return Task.CompletedTask;
}
if (s.StartsWith("__del__"))
{
var name = s["__del__".Length..];
var entry = _settings.Settings.Macros
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (entry != null)
{
_settings.Settings.Macros.Remove(entry);
_settings.Save();
NotificationService.Notify("AX Copilot", $"매크로 '{name}' 삭제됨");
}
return Task.CompletedTask;
}
}
// 매크로 항목 Enter → 실행
if (item.Data is MacroEntry macro)
{
_ = RunMacroAsync(macro, ct);
}
return Task.CompletedTask;
}
// ─── 매크로 재생 ──────────────────────────────────────────────────────
internal static async Task RunMacroAsync(MacroEntry macro, CancellationToken ct)
{
int executed = 0;
foreach (var step in macro.Steps)
{
if (ct.IsCancellationRequested) break;
if (step.DelayMs > 0)
await Task.Delay(step.DelayMs, ct).ConfigureAwait(false);
try
{
switch (step.Type.ToLowerInvariant())
{
case "app":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo
{
FileName = step.Target,
Arguments = step.Args ?? "",
UseShellExecute = true
});
break;
case "url":
case "folder":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo(step.Target)
{ UseShellExecute = true });
break;
case "notification":
var msg = string.IsNullOrWhiteSpace(step.Label) ? step.Target : step.Label;
NotificationService.Notify($"[매크로] {macro.Name}", msg);
break;
case "cmd":
if (!string.IsNullOrWhiteSpace(step.Target))
Process.Start(new ProcessStartInfo("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -Command \"{step.Target}\"")
{ UseShellExecute = false, CreateNoWindow = true });
break;
}
executed++;
}
catch (Exception ex)
{
LogService.Warn($"매크로 단계 실행 실패 '{step.Label}': {ex.Message}");
}
}
NotificationService.Notify("매크로 완료",
$"[{macro.Name}] {executed}/{macro.Steps.Count}단계 실행됨");
}
}

View File

@@ -363,6 +363,72 @@ public class ScheduleEntry
[JsonPropertyName("lastRun")]
public DateTime? LastRun { get; set; }
// ─── 조건 (L6-4) ─────────────────────────────────────────────────────
/// <summary>
/// 실행 조건: 특정 프로세스가 실행 중이어야(또는 아니어야) 트리거를 발화합니다.
/// 비어 있으면 조건 없음.
/// </summary>
[JsonPropertyName("conditionProcess")]
public string ConditionProcess { get; set; } = "";
/// <summary>
/// true=해당 프로세스가 실행 중일 때만 발화.
/// false=해당 프로세스가 실행 중이지 않을 때만 발화.
/// </summary>
[JsonPropertyName("conditionProcessMustRun")]
public bool ConditionProcessMustRun { get; set; } = true;
}
// ─── 런처 매크로 (L6-2) ─────────────────────────────────────────────────────────
/// <summary>
/// 런처 명령 시퀀스를 저장하는 매크로 항목.
/// macro 핸들러로 생성·편집·실행합니다.
/// </summary>
public class MacroEntry
{
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("steps")]
public List<MacroStep> Steps { get; set; } = new();
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
/// <summary>
/// 매크로 단일 단계.
/// </summary>
public class MacroStep
{
/// <summary>실행 유형. app | url | folder | notification | cmd</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "app";
/// <summary>앱 경로 / URL / 폴더 경로 / 알림 메시지 / PowerShell 명령</summary>
[JsonPropertyName("target")]
public string Target { get; set; } = "";
/// <summary>추가 인자 (app 유형 전용)</summary>
[JsonPropertyName("args")]
public string Args { get; set; } = "";
/// <summary>표시 이름</summary>
[JsonPropertyName("label")]
public string Label { get; set; } = "";
/// <summary>이 단계 실행 전 대기 시간(ms). 기본값 500.</summary>
[JsonPropertyName("delayMs")]
public int DelayMs { get; set; } = 500;
}
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────

View File

@@ -124,6 +124,12 @@ public class AppSettings
[JsonPropertyName("schedules")]
public List<ScheduleEntry> Schedules { get; set; } = new();
/// <summary>
/// 런처 매크로 목록. macro 핸들러로 생성·편집·실행합니다.
/// </summary>
[JsonPropertyName("macros")]
public List<MacroEntry> Macros { get; set; } = new();
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}

View File

@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.IO;
using System.Windows;
using AxCopilot.Models;
using System.Linq;
namespace AxCopilot.Services;
@@ -90,7 +91,7 @@ public sealed class SchedulerService : IDisposable
entry.LastRun.Value.Date == now.Date)
return false;
return entry.TriggerType switch
bool typeMatch = entry.TriggerType switch
{
"daily" => true,
"weekdays" => now.DayOfWeek >= DayOfWeek.Monday &&
@@ -103,6 +104,22 @@ public sealed class SchedulerService : IDisposable
now.Date == d.Date,
_ => false
};
if (!typeMatch) return false;
// ─── L6-4: 프로세스 조건 검사 ─────────────────────────────────────
if (!string.IsNullOrWhiteSpace(entry.ConditionProcess))
{
var procName = entry.ConditionProcess.Trim()
.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);
bool isRunning = Process.GetProcessesByName(procName).Length > 0;
if (entry.ConditionProcessMustRun && !isRunning) return false;
if (!entry.ConditionProcessMustRun && isRunning) return false;
}
return true;
}
// ─── 액션 실행 ────────────────────────────────────────────────────────

View File

@@ -0,0 +1,179 @@
<Window x:Class="AxCopilot.Views.MacroEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Commander — 매크로 편집"
Width="560" Height="520"
MinWidth="460" MinHeight="420"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ShowInTaskbar="False">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="22" ShadowDepth="4" Opacity="0.32" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/> <!-- 타이틀바 -->
<RowDefinition Height="Auto"/> <!-- 이름/설명 -->
<RowDefinition Height="*"/> <!-- 단계 목록 -->
<RowDefinition Height="Auto"/> <!-- 하단 버튼 -->
</Grid.RowDefinitions>
<!-- ─── 타이틀바 ─────────────────────────────────────────────── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid Margin="14,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE768;"
FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock Text="매크로 편집"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<Border HorizontalAlignment="Right" VerticalAlignment="Center"
CornerRadius="4" Padding="8,4" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40C05050"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
</Border>
<!-- ─── 이름/설명 ─────────────────────────────────────────────── -->
<StackPanel Grid.Row="1" Margin="18,14,18,8">
<TextBlock Text="매크로 이름" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="NameBox"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,6" Margin="0,0,0,10"/>
<TextBlock Text="설명 (선택)" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="DescBox"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5" Margin="0,0,0,10"/>
<!-- 열 헤더 -->
<Border Height="1" Background="{DynamicResource BorderColor}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="70"/>
<ColumnDefinition Width="28"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="유형" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}"/>
<TextBlock Grid.Column="1" Text="대상 (경로/URL/명령)" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="4,0,0,0"/>
<TextBlock Grid.Column="2" Text="표시 이름" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="4,0,0,0"/>
<TextBlock Grid.Column="3" Text="딜레이(ms)" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="4,0,0,0"/>
</Grid>
</StackPanel>
<!-- ─── 단계 목록 ─────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="2" Margin="18,0,18,0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="StepsPanel"/>
</ScrollViewer>
<!-- ─── 하단 버튼 바 ─────────────────────────────────────────── -->
<Border Grid.Row="3" CornerRadius="0,0,12,12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="12,8">
<Grid>
<!-- 단계 추가 -->
<Border CornerRadius="4" Padding="12,5" Cursor="Hand"
HorizontalAlignment="Left"
MouseLeftButtonUp="BtnAddStep_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#18FFFFFF"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#28FFFFFF"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE710;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="단계 추가" FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
</Border>
<!-- 취소·저장 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border CornerRadius="4" Padding="14,5" Cursor="Hand" Margin="0,0,8,0"
MouseLeftButtonUp="BtnCancel_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#18FFFFFF"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#28FFFFFF"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="취소" FontSize="12"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border CornerRadius="4" Padding="16,5" Cursor="Hand"
MouseLeftButtonUp="BtnSave_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AccentColor}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.85"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="White" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="저장" FontSize="12" FontWeight="SemiBold" Foreground="White"/>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,320 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class MacroEditorWindow : Window
{
private readonly SettingsService _settings;
private readonly MacroEntry? _editing;
// 유형 목록
private static readonly string[] StepTypes = { "app", "url", "folder", "notification", "cmd" };
private static readonly string[] StepTypeLabels = { "앱", "URL", "폴더", "알림", "PowerShell" };
// 각 행의 컨트롤 참조
private readonly List<StepRowUi> _rows = new();
// 공유 타입 팝업
private readonly Popup _typePopup = new()
{
StaysOpen = false,
AllowsTransparency = true,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom
};
private StepRowUi? _typeTargetRow;
public MacroEditorWindow(MacroEntry? entry, SettingsService settings)
{
InitializeComponent();
_settings = settings;
_editing = entry;
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
BuildTypePopup();
if (_editing != null)
{
NameBox.Text = _editing.Name;
DescBox.Text = _editing.Description;
foreach (var step in _editing.Steps)
AddRow(step);
}
if (_rows.Count == 0)
AddRow(null); // 기본 빈 행
}
// ─── 팝업 빌드 ──────────────────────────────────────────────────────────
private void BuildTypePopup()
{
var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var hover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Gray;
var panel = new StackPanel { Background = bg, MinWidth = 100 };
for (int i = 0; i < StepTypes.Length; i++)
{
var idx = i;
var label = StepTypeLabels[i];
var item = new Border
{
Padding = new Thickness(12, 6, 12, 6),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Tag = idx
};
item.MouseEnter += (_, _) => item.Background = hover;
item.MouseLeave += (_, _) => item.Background = Brushes.Transparent;
item.MouseLeftButtonUp += (_, _) =>
{
if (_typeTargetRow != null)
SetRowType(_typeTargetRow, idx);
_typePopup.IsOpen = false;
};
item.Child = new TextBlock
{
Text = label,
FontSize = 12,
Foreground = fg
};
panel.Children.Add(item);
}
var outerBorder = new Border
{
Background = bg,
BorderBrush = border,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Child = panel,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 12,
ShadowDepth = 3,
Opacity = 0.3,
Color = Colors.Black,
Direction = 270
}
};
_typePopup.Child = outerBorder;
}
// ─── 행 추가 ────────────────────────────────────────────────────────────
private void AddRow(MacroStep? step)
{
var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primFg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var row = new StepRowUi();
var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
// Col 0: 유형 버튼
var typeLbl = new TextBlock
{
FontSize = 11,
VerticalAlignment = VerticalAlignment.Center,
Foreground = primFg,
Text = StepTypeLabels[0]
};
var typeBtn = new Border
{
Background = bg,
BorderBrush = border,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 4, 6, 4),
Cursor = Cursors.Hand,
Child = typeLbl,
Margin = new Thickness(0, 0, 4, 0)
};
typeBtn.MouseLeftButtonUp += (_, _) =>
{
_typeTargetRow = row;
_typePopup.PlacementTarget = typeBtn;
_typePopup.IsOpen = true;
};
row.TypeButton = typeBtn;
row.TypeLabel = typeLbl;
Grid.SetColumn(typeBtn, 0);
// Col 1: 대상
var targetBox = new TextBox
{
FontSize = 11,
Foreground = primFg,
Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black,
BorderBrush = border,
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
Text = step?.Target ?? ""
};
row.TargetBox = targetBox;
Grid.SetColumn(targetBox, 1);
// Col 2: 표시 이름
var labelBox = new TextBox
{
FontSize = 11,
Foreground = secFg,
Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black,
BorderBrush = border,
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
Text = step?.Label ?? ""
};
row.LabelBox = labelBox;
Grid.SetColumn(labelBox, 2);
// Col 3: 딜레이
var delayBox = new TextBox
{
FontSize = 11,
Foreground = primFg,
Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black,
BorderBrush = border,
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
Text = (step?.DelayMs ?? 500).ToString()
};
row.DelayBox = delayBox;
Grid.SetColumn(delayBox, 3);
// Col 4: 삭제
var delBtn = new Border
{
Width = 24,
Height = 24,
CornerRadius = new CornerRadius(4),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = secFg,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
}
};
delBtn.MouseEnter += (_, _) => delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xC0, 0x50, 0x50));
delBtn.MouseLeave += (_, _) => delBtn.Background = Brushes.Transparent;
delBtn.MouseLeftButtonUp += (_, _) => RemoveRow(row);
Grid.SetColumn(delBtn, 4);
grid.Children.Add(typeBtn);
grid.Children.Add(targetBox);
grid.Children.Add(labelBox);
grid.Children.Add(delayBox);
grid.Children.Add(delBtn);
row.Grid = grid;
// 유형 초기화
int typeIdx = step != null ? Array.IndexOf(StepTypes, step.Type.ToLowerInvariant()) : 0;
if (typeIdx < 0) typeIdx = 0;
SetRowType(row, typeIdx);
_rows.Add(row);
StepsPanel.Children.Add(grid);
}
private void SetRowType(StepRowUi row, int typeIdx)
{
row.TypeIndex = typeIdx;
row.TypeLabel.Text = StepTypeLabels[typeIdx];
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
row.TypeLabel.Foreground = accent;
}
private void RemoveRow(StepRowUi row)
{
StepsPanel.Children.Remove(row.Grid);
_rows.Remove(row);
}
// ─── 버튼 이벤트 ─────────────────────────────────────────────────────────
private void BtnAddStep_Click(object sender, MouseButtonEventArgs e) => AddRow(null);
private void BtnSave_Click(object sender, MouseButtonEventArgs e)
{
var name = NameBox.Text.Trim();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.Show("매크로 이름을 입력하세요.", "저장 오류",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var steps = _rows
.Where(r => !string.IsNullOrWhiteSpace(r.TargetBox.Text))
.Select(r => new MacroStep
{
Type = StepTypes[r.TypeIndex],
Target = r.TargetBox.Text.Trim(),
Label = r.LabelBox.Text.Trim(),
DelayMs = int.TryParse(r.DelayBox.Text, out var d) ? Math.Max(0, d) : 500
})
.ToList();
var entry = _editing ?? new MacroEntry();
entry.Name = name;
entry.Description = DescBox.Text.Trim();
entry.Steps = steps;
if (_editing == null)
_settings.Settings.Macros.Add(entry);
_settings.Save();
NotificationService.Notify("AX Copilot", $"매크로 '{entry.Name}' 저장됨");
Close();
}
private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close();
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed) DragMove();
}
// ─── 내부 행 참조 클래스 ──────────────────────────────────────────────────
private class StepRowUi
{
public Grid Grid = null!;
public Border TypeButton = null!;
public TextBlock TypeLabel = null!;
public TextBox TargetBox = null!;
public TextBox LabelBox = null!;
public TextBox DelayBox = null!;
public int TypeIndex;
}
}

View File

@@ -236,6 +236,42 @@
AcceptsReturn="False"/>
</StackPanel>
<!-- ── 조건 (L6-4) ── -->
<Border Height="1" Background="{DynamicResource BorderColor}" Margin="0,14,0,14"/>
<TextBlock Text="실행 조건 (선택)" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="프로세스 이름"
FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,12,0" Width="100"/>
<TextBox Grid.Column="1" x:Name="ConditionProcessBox"
FontFamily="Cascadia Code, Consolas"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5" MaxWidth="180" HorizontalAlignment="Left"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<Border x:Name="BtnCondRun" CornerRadius="4,0,0,4" Padding="12,4" Cursor="Hand"
MouseLeftButtonUp="ConditionMode_Click" Tag="run">
<TextBlock x:Name="TxtCondRun" Text="실행 중일 때" FontSize="11"/>
</Border>
<Border x:Name="BtnCondNotRun" CornerRadius="0,4,4,0" Padding="12,4" Cursor="Hand"
MouseLeftButtonUp="ConditionMode_Click" Tag="notrun">
<TextBlock x:Name="TxtCondNotRun" Text="실행 중 아닐 때" FontSize="11"/>
</Border>
</StackPanel>
<TextBlock Text="비어 있으면 조건 없이 항상 실행합니다 · 예: chrome, code, slack"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
Margin="0,4,0,0"/>
</StackPanel>
</ScrollViewer>

View File

@@ -14,9 +14,10 @@ public partial class ScheduleEditorWindow : Window
private readonly SettingsService _settings;
private readonly ScheduleEntry? _editing; // null = 새 스케줄
private string _triggerType = "daily";
private string _actionType = "app";
private bool _enabled = true;
private string _triggerType = "daily";
private string _actionType = "app";
private bool _enabled = true;
private bool _conditionMustRun = true;
// 요일 버튼 → Border 참조
private Border[] _dayBtns = null!;
@@ -57,6 +58,7 @@ public partial class ScheduleEditorWindow : Window
SetActionUi(_actionType);
UpdateToggleUi(_enabled);
SetConditionModeUi(_conditionMustRun);
}
private void LoadFromEntry(ScheduleEntry e)
@@ -88,6 +90,11 @@ public partial class ScheduleEditorWindow : Window
{
NotifMsgBox.Text = e.ActionTarget;
}
// 조건 복원
ConditionProcessBox.Text = e.ConditionProcess ?? "";
_conditionMustRun = e.ConditionProcessMustRun;
SetConditionModeUi(_conditionMustRun);
}
// ─── 트리거 유형 ─────────────────────────────────────────────────────────
@@ -206,6 +213,27 @@ public partial class ScheduleEditorWindow : Window
AppPathBox.Text = dlg.FileName;
}
// ─── 조건 모드 (L6-4) ───────────────────────────────────────────────────
private void ConditionMode_Click(object sender, MouseButtonEventArgs e)
{
if (sender is Border b && b.Tag is string tag)
SetConditionModeUi(tag == "run");
}
private void SetConditionModeUi(bool mustRun)
{
_conditionMustRun = mustRun;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
BtnCondRun.Background = mustRun ? accent : dimBg;
BtnCondNotRun.Background = !mustRun ? accent : dimBg;
TxtCondRun.Foreground = mustRun ? Brushes.White : secFg;
TxtCondNotRun.Foreground = !mustRun ? Brushes.White : secFg;
}
// ─── 활성화 토글 ─────────────────────────────────────────────────────────
private void EnabledToggle_Click(object sender, MouseButtonEventArgs e)
{
@@ -283,6 +311,10 @@ public partial class ScheduleEditorWindow : Window
: NotifMsgBox.Text.Trim();
entry.ActionArgs = _actionType == "app" ? AppArgsBox.Text.Trim() : "";
// 조건 저장 (L6-4)
entry.ConditionProcess = ConditionProcessBox.Text.Trim();
entry.ConditionProcessMustRun = _conditionMustRun;
var schedules = _settings.Settings.Schedules;
if (_editing == null)