[Phase UX] 창 위치 기억·파일 아이콘·퍼지 랭킹·미리보기 패널 4종 UX 개선

창 위치 기억 (Feature 1):
- AppSettings.cs: LauncherSettings에 RememberPosition, LastLeft, LastTop 프로퍼티 추가
- SettingsViewModel.cs/.Properties.cs/.Methods.cs: RememberPosition 바인딩 프로퍼티 연동
- LauncherWindow.Animations.cs: CenterOnScreen() — RememberPosition ON 시 저장 좌표 복원
- LauncherWindow.Shell.cs: Window_Deactivated — 비활성화 시 현재 위치 비동기 저장
- SettingsWindow.xaml: 런처 탭 › "마지막 위치 기억" 토글 추가

파일 아이콘 표시 (Feature 2):
- Services/IconCacheService.cs (신규, 192줄): Shell32 SHGetFileInfo로 아이콘 추출,
  %LOCALAPPDATA%\AxCopilot\IconCache\에 PNG 캐시, WarmUp()으로 앱 시작 시 미리 준비
- Core/CommandResolver.cs: 퍼지 검색 결과에 IconCacheService.GetIconPath() 연결
- Handlers/FileBrowserHandler.cs: 상위폴더·폴더·파일 항목에 IconCacheService 연결
- App.xaml.cs: SystemIdle 시점에 IconCacheService.WarmUp() 호출

퍼지 검색 랭킹 개선 (Feature 3):
- Services/UsageRankingService.cs 전면 개선: 기존 int 횟수 → UsageRecord{Count, LastUsedMs}
- GetScore() 반환형 int → double, 30일 반감기 지수 감쇠(decay=exp(-days/43.3)) 적용
- 구형 usage.json 자동 마이그레이션 (count만 있는 형식 → 신규 형식)
- GetTopItems() / SortByUsage() 점수 기준 정렬로 업데이트

미리보기 패널 (Feature 4):
- ViewModels/LauncherViewModel.cs: PreviewText, HasPreview 프로퍼티 + UpdatePreviewAsync()
  클립보드 텍스트(최대 400자) 및 텍스트 파일(최초 6줄) 미리보기, 80ms 디바운스
- Views/LauncherWindow.xaml: RowDefinitions 7→8개, Row5에 PreviewPanel Border 삽입,
  IndexStatusText Row5→6, WidgetBar Row6→7, ToastOverlay RowSpan 3→4

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 17:38:12 +09:00
parent a53eecbc77
commit e33f8ac620
14 changed files with 442 additions and 48 deletions

View File

@@ -367,6 +367,11 @@ public partial class App : System.Windows.Application
() => _clipboardHistory?.Initialize(),
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
// ─── 파일 아이콘 캐시 워밍업 (앱 유휴 시점에 자주 쓰는 확장자 미리 추출) ──
Dispatcher.BeginInvoke(
() => AxCopilot.Services.IconCacheService.WarmUp(),
System.Windows.Threading.DispatcherPriority.SystemIdle);
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ──────────
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림
Dispatcher.BeginInvoke(

View File

@@ -107,7 +107,7 @@ public class CommandResolver
"batch" => "명령 단축키",
_ => r.Entry.Path
} : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기",
null,
IconCacheService.GetIconPath(r.Entry.Path, r.Entry.Type == IndexEntryType.Folder),
r.Entry,
Symbol: r.Entry.Type switch
{

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.Text.RegularExpressions;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
@@ -87,7 +88,7 @@ public class FileBrowserHandler : IActionHandler
items.Add(new LauncherItem(
".. (상위 폴더)",
parent,
null,
IconCacheService.GetIconPath(parent, true),
new FileBrowserEntry(parent, true),
Symbol: "\uE74A")); // Back 아이콘
}
@@ -107,7 +108,7 @@ public class FileBrowserHandler : IActionHandler
items.Add(new LauncherItem(
name,
d,
null,
IconCacheService.GetIconPath(d, true),
new FileBrowserEntry(d, true),
Symbol: Symbols.Folder));
}
@@ -127,7 +128,7 @@ public class FileBrowserHandler : IActionHandler
items.Add(new LauncherItem(
name,
$"{size} · {ext.TrimStart('.')} 파일",
null,
IconCacheService.GetIconPath(f),
new FileBrowserEntry(f, false),
Symbol: ExtToSymbol(ext)));
}

View File

@@ -283,6 +283,18 @@ public class LauncherSettings
/// </summary>
[JsonPropertyName("monitorDockPositions")]
public Dictionary<string, double[]> MonitorDockPositions { get; set; } = new();
/// <summary>AX Commander 마지막 위치 기억 여부. true이면 닫은 위치에서 다시 열립니다. 기본 false.</summary>
[JsonPropertyName("rememberPosition")]
public bool RememberPosition { get; set; } = false;
/// <summary>AX Commander 마지막 Left 좌표 (-1이면 기본 위치).</summary>
[JsonPropertyName("lastLeft")]
public double LastLeft { get; set; } = -1;
/// <summary>AX Commander 마지막 Top 좌표 (-1이면 기본 위치).</summary>
[JsonPropertyName("lastTop")]
public double LastTop { get; set; } = -1;
}
/// <summary>

View File

@@ -0,0 +1,192 @@
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
namespace AxCopilot.Services;
/// <summary>
/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다.
/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png
/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다.
/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다.
/// </summary>
internal static class IconCacheService
{
private static readonly string _cacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AxCopilot", "IconCache");
/// <summary>key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패)</summary>
private static readonly ConcurrentDictionary<string, string?> _cache =
new(StringComparer.OrdinalIgnoreCase);
private static volatile bool _warmupDone;
// ─── Win32 ──────────────────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
}
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SHGetFileInfo(
string pszPath, uint dwFileAttributes,
ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
private const uint SHGFI_ICON = 0x000000100;
private const uint SHGFI_LARGEICON = 0x000000000;
private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
// ─── 공개 API ───────────────────────────────────────────────────────────
/// <summary>
/// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다.
/// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다.
/// </summary>
public static string? GetIconPath(string filePath, bool isDirectory = false)
{
var key = isDirectory ? "folder" : GetExtKey(filePath);
if (_cache.TryGetValue(key, out var cached)) return cached;
_ = ExtractAsync(filePath, key, isDirectory);
return null;
}
/// <summary>
/// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다.
/// 중복 실행은 자동으로 건너뜁니다.
/// </summary>
public static void WarmUp()
{
if (_warmupDone) return;
_warmupDone = true;
// 캐시 디렉터리에 이미 저장된 PNG 로드 (재시작 시 빠른 복원)
_ = Task.Run(() =>
{
try
{
Directory.CreateDirectory(_cacheDir);
foreach (var file in Directory.GetFiles(_cacheDir, "*.png"))
{
var key = Path.GetFileNameWithoutExtension(file);
_cache.TryAdd(key, file);
}
}
catch { }
});
// 자주 쓰는 확장자 사전 추출 (UI 스레드 부하를 분산하기 위해 순차 실행)
_ = Task.Run(async () =>
{
await Task.Delay(2000); // 앱 초기화 완료 후 추출 시작
var common = new (string Path, string Key, bool IsDir)[]
{
("dummy.exe", "exe", false),
("dummy.lnk", "lnk", false),
("dummy.pdf", "pdf", false),
("dummy.docx", "docx", false),
("dummy.xlsx", "xlsx", false),
("dummy.pptx", "pptx", false),
("dummy.txt", "txt", false),
("dummy.png", "png", false),
("dummy.zip", "zip", false),
("dummy.mp4", "mp4", false),
("C:\\", "folder", true),
};
foreach (var (path, key, isDir) in common)
{
if (_cache.ContainsKey(key)) continue;
await ExtractAsync(path, key, isDir);
await Task.Delay(80); // UI 스레드 부하 분산
}
});
}
// ─── 내부 ───────────────────────────────────────────────────────────────
private static string GetExtKey(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return string.IsNullOrEmpty(ext) ? "noext" : ext.TrimStart('.');
}
private static async Task ExtractAsync(string filePath, string key, bool isDirectory)
{
if (_cache.ContainsKey(key)) return;
try
{
Directory.CreateDirectory(_cacheDir);
var cachePath = Path.Combine(_cacheDir, key + ".png");
// 파일이 이미 캐시에 있으면 메모리 캐시에 등록만
if (File.Exists(cachePath))
{
_cache.TryAdd(key, cachePath);
return;
}
// SHGetFileInfo + HICON → PNG 변환은 반드시 STA(Dispatcher) 스레드에서
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher == null) return;
var saved = await dispatcher.InvokeAsync(() =>
TryExtractAndSave(filePath, cachePath, isDirectory));
_cache.TryAdd(key, saved ? cachePath : null);
}
catch (Exception ex)
{
LogService.Warn($"[IconCache] '{key}' 추출 실패: {ex.Message}");
_cache.TryAdd(key, null);
}
}
/// <summary>Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다.</summary>
private static bool TryExtractAndSave(string filePath, string cachePath, bool isDirectory)
{
var info = new SHFILEINFO();
uint flags = SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES;
uint attrs = isDirectory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL;
SHGetFileInfo(filePath, attrs, ref info, (uint)Marshal.SizeOf(info), flags);
if (info.hIcon == IntPtr.Zero) return false;
try
{
var bitmap = Imaging.CreateBitmapSourceFromHIcon(
info.hIcon, Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
bitmap.Freeze();
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = File.OpenWrite(cachePath);
encoder.Save(stream);
return true;
}
catch
{
return false;
}
finally
{
DestroyIcon(info.hIcon);
}
}
}

View File

@@ -1,12 +1,13 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// 런처 항목 실행 횟수 추적하여 퍼지 검색 결과 정렬에 활용합니다.
/// 런처 항목 실행 횟수와 최신성을 추적하여 퍼지 검색 결과 정렬에 활용합니다.
/// 저장 위치: %APPDATA%\AxCopilot\usage.json
/// 형식: { "키": 횟수 } — 키는 IndexEntry.Path (파일/폴더/앱 경로)
/// 점수 = 실행횟수 × 최신성감쇠 (30일 반감기: 30일 전 사용 ≈ 현재의 37%)
/// </summary>
internal static class UsageRankingService
{
@@ -14,57 +15,61 @@ internal static class UsageRankingService
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "usage.json");
private static Dictionary<string, int> _counts = new(StringComparer.OrdinalIgnoreCase);
private static Dictionary<string, UsageRecord> _data = new(StringComparer.OrdinalIgnoreCase);
private static bool _loaded = false;
private static readonly object _lock = new();
/// <summary>
/// 항목 실행 시 호출하여 카운트를 증가시킵니다.
/// </summary>
private sealed class UsageRecord
{
[JsonPropertyName("c")] public int Count { get; set; }
[JsonPropertyName("t")] public long LastUsedMs { get; set; }
}
// ─── 공개 API ──────────────────────────────────────────────────────────────
/// <summary>항목 실행 시 호출하여 카운트 및 최근 실행 시각을 갱신합니다.</summary>
public static void RecordExecution(string key)
{
if (string.IsNullOrWhiteSpace(key)) return;
EnsureLoaded();
lock (_lock)
{
_counts.TryGetValue(key, out var current);
_counts[key] = current + 1;
if (!_data.TryGetValue(key, out var rec))
rec = _data[key] = new UsageRecord();
rec.Count++;
rec.LastUsedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
_ = SaveAsync();
}
/// <summary>
/// 주어진 키의 실행 횟수를 반환합니다. 없으면 0.
/// </summary>
public static int GetScore(string key)
/// <summary>실행 횟수 × 최신성 감쇠 합산 점수를 반환합니다. 없으면 0.</summary>
public static double GetScore(string key)
{
if (string.IsNullOrWhiteSpace(key)) return 0;
EnsureLoaded();
lock (_lock)
{
return _counts.TryGetValue(key, out var count) ? count : 0;
}
UsageRecord? rec;
lock (_lock) { _data.TryGetValue(key, out rec); }
if (rec == null) return 0;
// 최신성 감쇠: 30일 반감기
var daysSince = (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - rec.LastUsedMs) / 86_400_000.0;
var decay = Math.Exp(-daysSince / 43.3); // ln(2)/30 ≈ 1/43.3 → 30일에 0.5배
return rec.Count * decay;
}
/// <summary>
/// 실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.
/// 퀵 액션 바 표시에 활용됩니다.
/// </summary>
/// <summary>실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.</summary>
public static IReadOnlyList<(string Path, int Count)> GetTopItems(int n)
{
EnsureLoaded();
lock (_lock)
return _counts
.OrderByDescending(kv => kv.Value)
return _data
.OrderByDescending(kv => GetScore(kv.Key))
.Take(n)
.Select(kv => (kv.Key, kv.Value))
.Select(kv => (kv.Key, kv.Value.Count))
.ToList();
}
/// <summary>
/// 실행 횟수 기준으로 내림차순 정렬하는 컴파러를 반환합니다.
/// 동점이면 원래 순서 유지 (stable sort).
/// </summary>
/// <summary>실행 점수(횟수×최신성) 기준으로 내림차순 정렬합니다. 동점이면 원래 순서 유지.</summary>
public static IEnumerable<T> SortByUsage<T>(IEnumerable<T> items, Func<T, string?> keySelector)
{
EnsureLoaded();
@@ -75,7 +80,7 @@ internal static class UsageRankingService
.Select(x => x.item);
}
// ─── 내부 ──────────────────────────────────────────────────────────────────
// ─── 내부 ────────────────────────────────────────────────────────────────
private static void EnsureLoaded()
{
@@ -88,15 +93,40 @@ internal static class UsageRankingService
if (File.Exists(_dataFile))
{
var json = File.ReadAllText(_dataFile);
var data = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
if (data != null)
_counts = new Dictionary<string, int>(data, StringComparer.OrdinalIgnoreCase);
// 새 형식(UsageRecord) 먼저 시도
var newData = JsonSerializer.Deserialize<Dictionary<string, UsageRecord>>(json);
if (newData != null)
{
_data = new Dictionary<string, UsageRecord>(newData, StringComparer.OrdinalIgnoreCase);
_loaded = true;
return;
}
}
}
catch
{
// 구형식 마이그레이션 시도 (키:횟수 형식)
try
{
if (File.Exists(_dataFile))
{
var json = File.ReadAllText(_dataFile);
var oldData = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
if (oldData != null)
{
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_data = new Dictionary<string, UsageRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in oldData)
_data[kv.Key] = new UsageRecord { Count = kv.Value, LastUsedMs = nowMs };
LogService.Info("[UsageRanking] 구형 usage.json 마이그레이션 완료");
}
}
}
catch (Exception ex)
{
LogService.Warn($"usage.json 로드 실패: {ex.Message}");
}
}
_loaded = true;
}
}
@@ -105,8 +135,8 @@ internal static class UsageRankingService
{
try
{
Dictionary<string, int> snapshot;
lock (_lock) { snapshot = new Dictionary<string, int>(_counts); }
Dictionary<string, UsageRecord> snapshot;
lock (_lock) { snapshot = new Dictionary<string, UsageRecord>(_data); }
Directory.CreateDirectory(Path.GetDirectoryName(_dataFile)!);
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = false });

View File

@@ -100,10 +100,32 @@ public partial class LauncherViewModel : INotifyPropertyChanged
}
}
// ─── 미리보기 패널 ────────────────────────────────────────────────────────
private string _previewText = "";
private bool _hasPreview;
private CancellationTokenSource? _previewCts;
public string PreviewText
{
get => _previewText;
private set { _previewText = value; OnPropertyChanged(); }
}
public bool HasPreview
{
get => _hasPreview;
private set { _hasPreview = value; OnPropertyChanged(); }
}
public LauncherItem? SelectedItem
{
get => _selectedItem;
set { _selectedItem = value; OnPropertyChanged(); }
set
{
_selectedItem = value;
OnPropertyChanged();
_ = UpdatePreviewAsync(value);
}
}
public bool IsLoading
@@ -518,4 +540,67 @@ public partial class LauncherViewModel : INotifyPropertyChanged
// 기본: 제목
return SelectedItem.Title;
}
// ─── 미리보기 패널 업데이트 ───────────────────────────────────────────────
private async Task UpdatePreviewAsync(LauncherItem? item)
{
_previewCts?.Cancel();
_previewCts = new CancellationTokenSource();
var ct = _previewCts.Token;
if (item == null) { HasPreview = false; return; }
try
{
await Task.Delay(80, ct); // 빠른 탐색 시 불필요한 파일 읽기 방지
// 클립보드 텍스트
if (item.Data is ClipboardEntry clipEntry && clipEntry.IsText)
{
var text = clipEntry.Text ?? "";
PreviewText = text.Length > 400 ? text[..400] + "…" : text;
HasPreview = !string.IsNullOrEmpty(PreviewText);
return;
}
// 인덱스 파일 (IndexEntry)
if (item.Data is IndexEntry indexEntry)
{
var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant();
if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path))
{
var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct);
if (ct.IsCancellationRequested) return;
PreviewText = string.Join("\n", lines);
HasPreview = !string.IsNullOrEmpty(PreviewText.Trim());
return;
}
}
HasPreview = false;
}
catch (OperationCanceledException) { }
catch { HasPreview = false; }
}
private static bool IsPreviewableTextFile(string ext) => ext is
".txt" or ".md" or ".log" or ".csv" or ".json" or ".xml"
or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf"
or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css";
private static async Task<IEnumerable<string>> ReadFirstLinesAsync(
string path, int maxLines, CancellationToken ct)
{
var lines = new List<string>();
using var reader = new System.IO.StreamReader(path,
System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
for (int i = 0; i < maxLines && !reader.EndOfStream; i++)
{
ct.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(ct);
if (line != null) lines.Add(line);
}
return lines;
}
}

View File

@@ -182,6 +182,7 @@ public partial class SettingsViewModel
s.Launcher.EnableRecent = _enableRecent;
s.Launcher.EnableActionMode = _enableActionMode;
s.Launcher.CloseOnFocusLost = _closeOnFocusLost;
s.Launcher.RememberPosition = _rememberPosition;
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
s.Launcher.EnableIconAnimation = _enableIconAnimation;
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;

View File

@@ -37,6 +37,12 @@ public partial class SettingsViewModel
set { _closeOnFocusLost = value; OnPropertyChanged(); }
}
public bool RememberPosition
{
get => _rememberPosition;
set { _rememberPosition = value; OnPropertyChanged(); }
}
public bool ShowPrefixBadge
{
get => _showPrefixBadge;

View File

@@ -34,6 +34,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
private bool _enableRecent;
private bool _enableActionMode;
private bool _closeOnFocusLost;
private bool _rememberPosition;
private bool _showPrefixBadge;
private bool _enableIconAnimation;
private bool _enableRainbowGlow;
@@ -86,6 +87,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_enableRecent = s.Launcher.EnableRecent;
_enableActionMode = s.Launcher.EnableActionMode;
_closeOnFocusLost = s.Launcher.CloseOnFocusLost;
_rememberPosition = s.Launcher.RememberPosition;
_showPrefixBadge = s.Launcher.ShowPrefixBadge;
_enableIconAnimation = s.Launcher.EnableIconAnimation;
_enableRainbowGlow = s.Launcher.EnableRainbowGlow;

View File

@@ -112,18 +112,31 @@ public partial class LauncherWindow
/// <summary>
/// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다.
/// 듀얼 모니터 환경에서도 핫키를 누른 순간 마우스가 있는 화면에 나타납니다.
/// RememberPosition 설정이 켜진 경우 마지막 위치를 복원합니다.
/// </summary>
private void CenterOnScreen()
{
var area = GetCurrentMonitorWorkArea();
// ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호
// 위치 기억 기능: 저장된 위치 복원
var app = System.Windows.Application.Current as App;
var launcher = app?.SettingsService?.Settings.Launcher;
if (launcher?.RememberPosition == true && launcher.LastLeft >= 0 && launcher.LastTop >= 0)
{
var wa = GetCurrentMonitorWorkArea();
var w = ActualWidth > 0 ? ActualWidth : 640;
var h = ActualHeight > 0 ? ActualHeight : 80;
Left = (area.Width - w) / 2 + area.Left;
Left = Math.Clamp(launcher.LastLeft, wa.Left, Math.Max(wa.Left, wa.Right - w));
Top = Math.Clamp(launcher.LastTop, wa.Top, Math.Max(wa.Top, wa.Bottom - h));
return;
}
var area = GetCurrentMonitorWorkArea();
// ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호
var w2 = ActualWidth > 0 ? ActualWidth : 640;
var h2 = ActualHeight > 0 ? ActualHeight : 80;
Left = (area.Width - w2) / 2 + area.Left;
Top = _vm.WindowPosition switch
{
"center" => (area.Height - h) / 2 + area.Top,
"center" => (area.Height - h2) / 2 + area.Top,
"bottom" => area.Height * 0.75 + area.Top,
_ => area.Height * 0.2 + area.Top, // "center-top" (기본)
};

View File

@@ -214,6 +214,15 @@ public partial class LauncherWindow
private void Window_Deactivated(object sender, EventArgs e)
{
// 위치 기억 설정이 켜진 경우 현재 위치 저장
var app = System.Windows.Application.Current as App;
var s = app?.SettingsService?.Settings;
if (s?.Launcher.RememberPosition == true && IsVisible && Left >= 0 && Top >= 0)
{
s.Launcher.LastLeft = Left;
s.Launcher.LastTop = Top;
_ = Task.Run(() => { try { app!.SettingsService!.Save(); } catch { } });
}
// 설정 기능 "포커스 잃으면 닫기"가 켜진 경우에만 자동 숨김
if (_vm.CloseOnFocusLost) Hide();
}

View File

@@ -203,6 +203,7 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ─── 입력 영역 ─── -->
@@ -780,9 +781,32 @@
</ListView.ItemTemplate>
</ListView>
<!-- ─── 미리보기 패널 (선택 항목 텍스트 미리보기) ─── -->
<Border Grid.Row="5"
x:Name="PreviewPanel"
Visibility="{Binding HasPreview, Converter={StaticResource BoolToVisibilityConverter}}"
Background="{DynamicResource ItemBackground}"
CornerRadius="8"
Margin="10,0,10,8"
Padding="12,8"
MaxHeight="100">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBar}"/>
</ScrollViewer.Resources>
<TextBlock Text="{Binding PreviewText}"
FontFamily="Segoe UI Mono, Consolas, Malgun Gothic"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
TextWrapping="Wrap"
LineHeight="16"/>
</ScrollViewer>
</Border>
<!-- ─── 인덱싱 상태 바 ─── -->
<TextBlock x:Name="IndexStatusText"
Grid.Row="5"
Grid.Row="6"
Visibility="Collapsed"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
@@ -792,7 +816,7 @@
<!-- ─── 위젯 바 (Row A: 기존 4개 / Row B: 날씨·일정·배터리) ─── -->
<Border x:Name="WidgetBar"
Grid.Row="6"
Grid.Row="7"
BorderBrush="{DynamicResource SeparatorColor}"
BorderThickness="0,1,0,0"
Padding="10,7,10,9">
@@ -977,7 +1001,7 @@
<!-- ─── 토스트 오버레이 ─── -->
<Border x:Name="ToastOverlay"
Grid.Row="4" Grid.RowSpan="3"
Grid.Row="4" Grid.RowSpan="4"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,12"

View File

@@ -3165,6 +3165,20 @@
</Grid>
</Border>
<!-- 창 위치 기억 -->
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left">
<TextBlock Style="{StaticResource RowLabel}" Text="마지막 위치 기억"/>
<TextBlock Style="{StaticResource RowHint}"
Text="AX Commander를 닫은 위치를 기억하고 다음에 같은 위치에서 엽니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}"
HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding RememberPosition, Mode=TwoWay}"/>
</Grid>
</Border>
<!-- 액션 모드 -->
<Border Style="{StaticResource SettingsRow}">
<Grid>