diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index 475e2b9..b724fb8 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -1,4 +1,4 @@
-# 변경 이력
+# 변경 이력
| 날짜 | 내용 |
| --- | --- |
@@ -20,3 +20,14 @@
| 2026-09-21 | Shell.exe를 직접 실행하면 런처로 넘기도록 — 진입점을 런처 하나로 통일(업데이트 확인 누락 방지) |
| 2026-09-21 | 런처 창(진행·업데이트 안내)이 앱의 테마 설정(다크/라이트)을 따르도록 — 윈도우 설정만 보던 것을 수정 |
| 2026-09-21 | `PlatformChrome.FullBleed` 첨부 속성 추가 — 화면을 꽉 쓰는 모듈이 셸 여백을 끌 수 있게 (건의 6) |
+| 2026-09-28 | 하단 탭 줄 항목을 한 단계 작게(74 → 61 DIP) — 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5. 배율 1.2 에서 89 → 73px, 그만큼 모듈 화면이 넓어진다(MergeHub 12차 C-2). **모든 소비 프로그램의 탭 줄 높이가 함께 바뀐다** |
+| 2026-09-23 | `MessageUtil.ShowChoice` 추가 — 세 갈래로 묻는 창(저장하고 계속 · 저장하지 않고 계속 · 취소), Esc·창 닫기는 취소 |
+| 2026-09-23 | 덮는 창을 본 창의 **실제 자리** 가운데로(`OwnerCentering`) — 전체화면일 때 세로로 어긋나던 것 수정 |
+| 2026-09-23 | D 드라이브가 없는 PC 대응(`ConfigLocation`) — `%LOCALAPPDATA%\UbisamConfig` 로 넘어가고, 두 자리 다 못 쓰면 까닭을 적은 안내창 |
+| 2026-09-23 | 켤 때 터지면 창 없이 죽지 않고 안내창(로그 자리 포함)을 띄우고 끝내도록 |
+| 2026-09-23 | 셸 창 모양 기억(`ShellWindowPlacement.json`) — 마지막 모드(전체화면·창모드)와 창모드 자리·크기 |
+| 2026-09-23 | 켤 때 기억한 자리를 모니터 안으로(`ShellPlacementMath.FitToMonitors`) — 모니터를 떼거나 배율이 바뀌어도 창을 잡을 수 있게 |
+| 2026-09-23 | 최대화 크기를 그 모니터에 맞춤(`NeedsMaximizeFix`) — 배율이 크게 다른 모니터에서 오른쪽·아래가 잘리던 것 수정 |
+| 2026-09-23 | 탭 줄 접기 추가 — 하단 [탭 접기]와 `F11`, 접으면 얇은 띠만 남고 띠를 누르면 펴짐(`UiSettingsService.NavCollapsed`) |
+| 2026-09-23 | 트레이 추가 — 최소화하면 알림 영역으로 내려가고, 왼쪽 단추로 되살리기 · 오른쪽 단추로 [열기]/[종료](WinForms 의존 없이 `Shell_NotifyIcon`) |
+| 2026-09-23 | `SettingsService.RegisterSettings` 에 같은 키 중복 등록 방지 — 코드가 쓰는 객체와 설정 화면이 고치는 객체가 갈라지던 문제 |
diff --git a/doc/platform-guide.md b/doc/platform-guide.md
index 765d228..69bde4a 100644
--- a/doc/platform-guide.md
+++ b/doc/platform-guide.md
@@ -1,4 +1,4 @@
-# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
+# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때
빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은
@@ -256,6 +256,13 @@ MessageUtil.ShowInfo("알림", "저장되었습니다.");
MessageUtil.ShowError("오류", "저장하지 못했습니다.");
bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?");
+// 세 갈래로 묻기 — 예/아니오로 가를 수 없는 자리("저장하고 계속 · 저장하지 않고 계속 · 취소").
+// 단추 글은 부르는 쪽이 정하고, Esc · 창 닫기는 Cancel 이다.
+DialogChoice choice = MessageUtil.ShowChoice(
+ "저장하지 않은 편집",
+ "Unit0.cs 에 저장하지 않은 편집이 있습니다.",
+ "저장하고 계속", "저장하지 않고 계속");
+
// 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃
MessageUtil.ShowToast("정보 메시지");
MessageUtil.ShowSuccessToast("저장되었습니다.");
@@ -266,6 +273,12 @@ MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다.");
토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다
(`UiSettingsService.ToastPosition`).
+**왜 세 갈래가 필요한가** — 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않습니다.
+아니오를 눌렀을 때 그대로 넘어가 버리면 그것이 곧 잃는 것입니다.
+
+덮는 창(대화상자)은 뜬 뒤에 **본 창의 실제 자리 가운데**로 맞춰집니다(`OwnerCentering`).
+WPF `CenterOwner`는 본 창이 전체화면일 때 "복원 자리"를 기준으로 삼아 세로로 어긋났습니다.
+
---
## 4. 차트 — `UbisamBase.Core.Charting.ChartTheme` (LiveCharts2)
@@ -505,6 +518,34 @@ public partial class DeckViewModel : ObservableObject, ICloseGuard
- **비주얼 트리를 거슬러 올라가 Border의 Padding을 직접 0으로 만들지 말 것.** 플랫폼 레이아웃이
바뀌면 그대로 깨진다 — 이 속성이 그 자리를 대신한다.
+---
+
+## 8-5. 창 모양 — 전체화면 · 창모드 · 최대화 · 트레이
+
+셸 창의 모양은 모듈이 손댈 것이 없습니다. 플랫폼이 알아서 하고, 사용자가 고른 것을 기억합니다.
+
+| 하는 일 | 어디서 | 기억하는 곳 |
+| --- | --- | --- |
+| 전체화면 ↔ 창모드 | 상단 바 오른쪽 단추, `Ctrl+Shift+F` | `ShellWindowPlacement.json` |
+| 창모드 자리·크기 | 마우스로 옮기고 끌기 | 같은 파일(닫을 때 씀) |
+| 최소화 → 트레이 | 하단 [바탕화면], 창 최소화 | — |
+| 탭 줄 접기 | 하단 [탭 접기], `F11` | `UiSettingsService.NavCollapsed` |
+
+- **켤 때** — 지난번에 창모드로 껐으면 그 자리·크기로, 아니면 전체화면으로 뜬다. 기억한 자리가
+ 지금 모니터 밖이면(모니터를 떼거나 해상도·배율이 바뀜) **가장 많이 겹치는 모니터 안으로** 민다.
+ 어느 모니터에도 안 걸치면 주 모니터 가운데다. 파일이 없거나 깨졌으면 전체화면 — 못 켜지는 일은 없다.
+- **최대화** — 배율이 크게 다른 모니터(주 150% 옆 300%)에서는 창이 모니터보다 크게 잡혀 오른쪽·아래가
+ 잘렸다(재현: 3840×2160 모니터에서 창 5120×2186). 최대화된 뒤 한 번 다시 놓아 맞춘다
+ (`ShellPlacementMath.NeedsMaximizeFix`). 이미 맞으면 손대지 않는다.
+- **트레이** — 최소화하면 작업 표시줄이 아니라 알림 영역(트레이)으로 내려간다. 아이콘을 왼쪽 단추로
+ 누르면 **내려가기 전 모양 그대로** 되살아나고, 오른쪽 단추로 누르면 [열기] · [종료] 가 나온다.
+ [종료]는 창을 되살린 뒤 평소 경로로 닫으므로 `ICloseGuard` 확인을 건너뛰지 않는다.
+ 아이콘은 실행 파일의 것을 쓴다 — 프로그램마다 자기 아이콘이 나온다. WinForms `NotifyIcon` 을 쓰지 않고
+ `Shell_NotifyIcon` 을 직접 부른다(플랫폼에 WinForms 의존을 더하지 않으려고).
+ 윈도우 11 은 처음 보는 트레이 아이콘을 **숨김 영역**에 넣는다 — 안 보이면 `^`(숨겨진 아이콘 표시) 안에 있다.
+- **탭 줄 접기** — 접으면 얇은 띠만 남고, 띠를 누르거나 `F11` 로 다시 편다. 접힘은 사용자 취향이라
+ UI 설정 파일에 그 항목만 따로 쓴다(설정 화면에서 저장하지 않은 값은 건드리지 않는다).
+
## 9. 테마 · UI 컨벤션
- `Ubisam.Brush.*`, `Ubisam.Style.*`로 시작하는 리소스가 다크/라이트 테마 전체를 정의
@@ -663,11 +704,18 @@ Reference를 추가해야 한다.
| 경로 | 내용 |
| --- | --- |
| `D:\UbisamConfig\Settings\` | 설정 클래스별 JSON |
+| `D:\UbisamConfig\Settings\ShellWindowPlacement.json` | 셸 창의 마지막 모양(전체화면 여부·창모드 자리) |
| `D:\UbisamConfig\Log\` | 날짜별 로그 (`yyyyMMdd.log`) |
| `D:\UbisamConfig\ScreenSaver\` | 화면 잠금 시 마지막 아이디/비밀번호 |
여러 프로그램이 이 플랫폼으로 만들어져도 전부 `D:\UbisamConfig` 한 곳에 모입니다.
+**D 드라이브가 없는 PC** — `D:\UbisamConfig` 를 만들 수 없으면(드라이브가 없거나, 같은 이름 파일이
+있거나, 권한이 없으면) `%LOCALAPPDATA%\UbisamConfig` 에 씁니다(`ConfigLocation`). 어디에 쓰는지는
+로그 첫 줄에 남습니다. 두 자리 모두 못 쓰면 **까닭을 적은 안내창**을 띄우고 끝냅니다 — 예전에는
+창 없는 프로세스만 남아 "켜지지 않는다"로 보였습니다. 설정 폴더 자리는 `UbisamAppBuilder.ConfigRoot`
+로 읽습니다(상수가 아니라 켤 때 정해지는 값이라 하드코딩하지 말 것).
+
---
## 12. 플랫폼 자동 업데이트 (깃)
diff --git a/src/UbisamBase.Core/Bootstrap/ConfigLocation.cs b/src/UbisamBase.Core/Bootstrap/ConfigLocation.cs
new file mode 100644
index 0000000..8525071
--- /dev/null
+++ b/src/UbisamBase.Core/Bootstrap/ConfigLocation.cs
@@ -0,0 +1,65 @@
+using System;
+using System.IO;
+
+namespace UbisamBase.Core.Bootstrap;
+
+///
+/// 설정 · 로그 · 화면 잠금 기록을 둘 자리를 정한다.
+///
+/// 보통은 (D:\UbisamConfig) 다 — 여러 프로그램이 이 플랫폼으로 만들어져도
+/// 한 곳에 모인다. D 드라이브가 없거나 그 자리에 폴더를 만들 수 없으면
+/// (%LOCALAPPDATA%\UbisamConfig)에 쓴다(MergeHub 11차 B-2, 2026-09-22 사용자 결정).
+///
+/// 예전에는 D:\ 고정이었고 폴더를 못 만들 때 감싼 곳이 없었다. 셸이 켜지다 말고 공용 예외 창을 띄운 뒤
+/// 창 없는 프로세스만 남았다(D:\UbisamConfig 자리에 같은 이름 파일을 두어 같은 줄에서 재현).
+/// D 드라이브가 있는 PC 에서는 예전과 똑같다.
+///
+public static class ConfigLocation
+{
+ /// 늘 먼저 쓰는 자리.
+ public const string Preferred = @"D:\UbisamConfig";
+
+ /// 먼저 쓰는 자리를 못 쓸 때의 자리 - Windows 계정마다 따로다.
+ public static string Fallback =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UbisamConfig");
+
+ ///
+ /// 쓸 자리를 고른다.
+ ///
+ /// 자리를 만들어 보고, 되면 null · 안 되면 까닭을 돌려준다().
+ /// 고른 자리와, 먼저 쓰는 자리를 못 쓴 까닭(썼으면 null).
+ /// 두 자리 모두 못 쓸 때 - 두 까닭을 함께 적는다.
+ public static (string Root, string? FallbackReason) Choose(string preferred, string fallback, Func tryCreate)
+ {
+ var first = tryCreate(preferred);
+ if (first is null) return (preferred, null);
+
+ var second = tryCreate(fallback);
+ if (second is null) return (fallback, first);
+
+ throw new IOException($"설정 폴더를 만들 수 없습니다.{Environment.NewLine}" +
+ $"{preferred} : {first}{Environment.NewLine}" +
+ $"{fallback} : {second}");
+ }
+
+ ///
+ /// 그 자리에 Settings 폴더를 만들어 본다. 되면 null, 안 되면 까닭.
+ /// 드라이브가 없으면 DirectoryNotFoundException, 같은 이름 파일이 있으면 IOException, 권한이 없으면 UnauthorizedAccessException 이다.
+ ///
+ public static string? TryCreate(string root)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(root, "Settings"));
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException)
+ {
+ return ex.Message;
+ }
+ }
+
+ /// 다른 자리를 쓰게 됐을 때 로그에 남길 글.
+ public static string FallbackNote(string preferred, string used, string reason) =>
+ $"{preferred} 를 쓸 수 없어 {used} 에 설정 · 로그를 둡니다 ({reason})";
+}
diff --git a/src/UbisamBase.Core/Bootstrap/StartupMessages.cs b/src/UbisamBase.Core/Bootstrap/StartupMessages.cs
new file mode 100644
index 0000000..67909bf
--- /dev/null
+++ b/src/UbisamBase.Core/Bootstrap/StartupMessages.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace UbisamBase.Core.Bootstrap;
+
+///
+/// 셸이 사람에게 보이는 실패 글(MergeHub 11차 B-2).
+///
+/// 예전 글은 켜지다 만 때에도 "프로그램은 계속 쓸 수 있습니다" · "로그 파일에 남았습니다" 라고 했다.
+/// 본 창이 안 떴으니 쓸 것이 없었고, 로그는 그보다 뒤에 준비되므로 아무 데도 안 남았다.
+/// 그래서 켜는 중 실패와 쓰는 중 실패를 나누고, 로그는 준비됐을 때만 자리를 댄다.
+///
+public static class StartupMessages
+{
+ /// 본 창이 뜨기 전에 실패했을 때. 이 글을 보인 뒤 프로그램을 끝낸다.
+ public static string StartFailed(string reason, string? logDirectory) =>
+ "프로그램을 켜지 못해 끝냅니다." + Environment.NewLine +
+ Environment.NewLine +
+ reason + LogHint(logDirectory);
+
+ /// 쓰는 도중 한 동작이 실패했을 때. 프로그램은 계속 돈다.
+ public static string ActionFailed(string reason, string? logDirectory) =>
+ "작업 중 문제가 생겨 이 동작을 멈췄습니다. 프로그램은 계속 쓸 수 있습니다." + Environment.NewLine +
+ Environment.NewLine +
+ reason + LogHint(logDirectory);
+
+ /// 로그가 준비됐을 때만 자리를 댄다 - 못 남긴 것을 남았다고 하지 않는다.
+ public static string LogHint(string? logDirectory) =>
+ string.IsNullOrEmpty(logDirectory)
+ ? string.Empty
+ : Environment.NewLine + Environment.NewLine + "자세한 내용은 " + logDirectory + " 아래 로그 파일에 남았습니다.";
+}
diff --git a/src/UbisamBase.Core/Bootstrap/UbisamAppBuilder.cs b/src/UbisamBase.Core/Bootstrap/UbisamAppBuilder.cs
index 56c04b5..f00e248 100644
--- a/src/UbisamBase.Core/Bootstrap/UbisamAppBuilder.cs
+++ b/src/UbisamBase.Core/Bootstrap/UbisamAppBuilder.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -24,17 +24,32 @@ namespace UbisamBase.Core.Bootstrap;
public static class UbisamAppBuilder
{
///
- /// 설정 JSON(Settings 폴더)이 저장되는 고정 위치. exe가 어디서 실행되든, 여러 프로그램이
- /// 이 플랫폼으로 만들어져도 항상 D:\UbisamConfig\Settings\ 아래에 모인다. 폴더가 없으면 자동 생성된다.
+ /// 설정 JSON(Settings 폴더) · 로그가 저장되는 자리. 여러 프로그램이 이 플랫폼으로 만들어져도 한 곳에 모인다.
+ ///
+ /// 보통은 D:\UbisamConfig 다. D 드라이브가 없거나 거기에 폴더를 만들 수 없으면
+ /// %LOCALAPPDATA%\UbisamConfig 를 쓴다().
+ /// 상수가 아니라 속성이다 - 상수는 소비 프로그램에 글자로 박혀, 다시 빌드하지 않으면 옛 자리를 계속 본다.
+ /// 전에는 다.
///
- public const string ConfigRoot = @"D:\UbisamConfig";
+ public static string ConfigRoot { get; private set; } = ConfigLocation.Preferred;
+
+ /// 를 못 쓰고 다른 자리를 쓴 까닭. 썼으면 null.
+ public static string? ConfigFallbackReason { get; private set; }
public static IServiceProvider Run(string basePath, Action? configureServices = null)
{
- var uiSettings = new UiSettingsService();
- uiSettings.Initialize(ConfigRoot);
+ // 자리를 먼저 고르고 로그부터 준비한다 - 뒤에서 무엇이 실패해도 까닭이 파일에 남는다.
+ (ConfigRoot, ConfigFallbackReason) = ConfigLocation.Choose(
+ ConfigLocation.Preferred, ConfigLocation.Fallback, ConfigLocation.TryCreate);
LogService.Current.Initialize(ConfigRoot);
+ if (ConfigFallbackReason is not null)
+ {
+ new Logger("Platform").W(ConfigLocation.FallbackNote(ConfigLocation.Preferred, ConfigRoot, ConfigFallbackReason));
+ }
+
+ var uiSettings = new UiSettingsService();
+ uiSettings.Initialize(ConfigRoot);
ButtonClickLogger.Install();
FullscreenHotkeyInstaller.Install();
diff --git a/src/UbisamBase.Core/Locking/LockConfirmDialog.xaml.cs b/src/UbisamBase.Core/Locking/LockConfirmDialog.xaml.cs
index 1288aec..1d381e5 100644
--- a/src/UbisamBase.Core/Locking/LockConfirmDialog.xaml.cs
+++ b/src/UbisamBase.Core/Locking/LockConfirmDialog.xaml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Text.Json;
using System.Windows;
@@ -29,6 +29,9 @@ public partial class LockConfirmDialog : Window
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
+
+ // 전체화면 셸 위에서는 CenterOwner 가 어긋난다 - 뜬 뒤 실제 자리로 맞춘다.
+ ContentRendered += (_, _) => Shell.OwnerCentering.CenterOnOwner(this);
}
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
diff --git a/src/UbisamBase.Core/Messaging/DialogChoice.cs b/src/UbisamBase.Core/Messaging/DialogChoice.cs
new file mode 100644
index 0000000..b27340d
--- /dev/null
+++ b/src/UbisamBase.Core/Messaging/DialogChoice.cs
@@ -0,0 +1,20 @@
+namespace UbisamBase.Core.Messaging;
+
+///
+/// 세 갈래로 묻는 창에서 사용자가 고른 것().
+///
+/// 예/아니오만으로는 가를 수 없는 자리에 쓴다 - "저장하고 계속 · 버리고 계속 · 취소" 처럼
+/// 계속할지 와 저장할지 를 한 번에 물어야 할 때. 단추 글은 부르는 쪽이 정하고,
+/// 여기서는 세 자리(주 동작 · 보조 동작 · 그만두기)만 가른다.
+///
+public enum DialogChoice
+{
+ /// 그만둔다. Esc · 창 닫기도 이것이다.
+ Cancel,
+
+ /// 주 동작(오른쪽 채운 단추).
+ Primary,
+
+ /// 보조 동작(가운데 단추).
+ Secondary
+}
diff --git a/src/UbisamBase.Core/Messaging/MessageDialog.xaml b/src/UbisamBase.Core/Messaging/MessageDialog.xaml
index 9659a7d..eedd1c5 100644
--- a/src/UbisamBase.Core/Messaging/MessageDialog.xaml
+++ b/src/UbisamBase.Core/Messaging/MessageDialog.xaml
@@ -1,4 +1,4 @@
-
+
+