플랫폼 묶음 - 트레이 · 창 자리 기억 · 세 갈래 묻는 창 · 탭 줄 크기

새 플랫폼(UbisamBase.Launcher 로 켜는 구조) 위에서 쌓인 변경을 한 번에 담는다.
자세한 것은 doc/history/changelog.md · doc/platform-guide.md.

- 트레이 아이콘(TrayIcon): 최소화하면 알림 영역으로, 왼쪽 단추로 되살리기 ·
  오른쪽 단추로 [열기]/[종료]. WinForms 의존 없이 Shell_NotifyIcon 만 쓴다
- 셸 창 모양 기억(ShellPlacement · ShellWindowPlacement.json): 마지막 모드와
  창모드 자리 · 크기. 켤 때 모니터 안으로 되돌리고(FitToMonitors), 배율이 크게 다른
  모니터에서 최대화가 오른쪽 · 아래로 넘치던 것을 고쳤다(NeedsMaximizeFix)
- 탭 줄 접기: 하단 [탭 접기]와 F11, 접으면 얇은 띠만 남고 띠를 누르면 펴진다
- MessageUtil.ShowChoice(DialogChoice): 세 갈래로 묻는 창 -
  예/아니오로는 버리고 가기와 그만두기를 가를 수 없다
- 덮는 창을 본 창의 실제 자리 가운데로(OwnerCentering) - 전체화면에서 세로로 어긋나던 것
- D 드라이브가 없는 PC 대응(ConfigLocation): %LOCALAPPDATA%\UbisamConfig 로 넘어가고,
  두 자리 다 못 쓰면 까닭을 적은 안내창(StartupMessages)
- 켤 때 터지면 창 없이 죽지 않고 로그 자리를 적은 안내창을 띄우고 끝낸다
- 설정 중복 등록 방지(SettingsService.RegisterSettings) - 코드가 쓰는 객체와
  설정 화면이 고치는 객체가 갈라지던 문제
- PlatformChrome.FullBleed 를 쓰는 화면을 위한 손질
- 하단 탭 줄 항목을 한 단계 작게(74 -> 61 DIP, 실측 133 -> 109px).
  모든 소비 프로그램의 탭 줄 높이가 함께 바뀐다(MergeHub 12차 C-2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-28 13:21:21 +09:00
parent 6991dfdfcf
commit 9af4bf3bdb
23 changed files with 1198 additions and 38 deletions

View File

@ -1,4 +1,4 @@
# 변경 이력 # 변경 이력
| 날짜 | 내용 | | 날짜 | 내용 |
| --- | --- | | --- | --- |
@ -20,3 +20,14 @@
| 2026-09-21 | Shell.exe를 직접 실행하면 런처로 넘기도록 — 진입점을 런처 하나로 통일(업데이트 확인 누락 방지) | | 2026-09-21 | Shell.exe를 직접 실행하면 런처로 넘기도록 — 진입점을 런처 하나로 통일(업데이트 확인 누락 방지) |
| 2026-09-21 | 런처 창(진행·업데이트 안내)이 앱의 테마 설정(다크/라이트)을 따르도록 — 윈도우 설정만 보던 것을 수정 | | 2026-09-21 | 런처 창(진행·업데이트 안내)이 앱의 테마 설정(다크/라이트)을 따르도록 — 윈도우 설정만 보던 것을 수정 |
| 2026-09-21 | `PlatformChrome.FullBleed` 첨부 속성 추가 — 화면을 꽉 쓰는 모듈이 셸 여백을 끌 수 있게 (건의 6) | | 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` 에 같은 키 중복 등록 방지 — 코드가 쓰는 객체와 설정 화면이 고치는 객체가 갈라지던 문제 |

View File

@ -1,4 +1,4 @@
# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용) # UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때 이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때
빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은 빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은
@ -256,6 +256,13 @@ MessageUtil.ShowInfo("알림", "저장되었습니다.");
MessageUtil.ShowError("오류", "저장하지 못했습니다."); MessageUtil.ShowError("오류", "저장하지 못했습니다.");
bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?"); bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?");
// 세 갈래로 묻기 — 예/아니오로 가를 수 없는 자리("저장하고 계속 · 저장하지 않고 계속 · 취소").
// 단추 글은 부르는 쪽이 정하고, Esc · 창 닫기는 Cancel 이다.
DialogChoice choice = MessageUtil.ShowChoice(
"저장하지 않은 편집",
"Unit0.cs 에 저장하지 않은 편집이 있습니다.",
"저장하고 계속", "저장하지 않고 계속");
// 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃 // 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃
MessageUtil.ShowToast("정보 메시지"); MessageUtil.ShowToast("정보 메시지");
MessageUtil.ShowSuccessToast("저장되었습니다."); MessageUtil.ShowSuccessToast("저장되었습니다.");
@ -266,6 +273,12 @@ MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다.");
토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다 토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다
(`UiSettingsService.ToastPosition`). (`UiSettingsService.ToastPosition`).
**왜 세 갈래가 필요한가** — 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않습니다.
아니오를 눌렀을 때 그대로 넘어가 버리면 그것이 곧 잃는 것입니다.
덮는 창(대화상자)은 뜬 뒤에 **본 창의 실제 자리 가운데**로 맞춰집니다(`OwnerCentering`).
WPF `CenterOwner`는 본 창이 전체화면일 때 "복원 자리"를 기준으로 삼아 세로로 어긋났습니다.
--- ---
## 4. 차트 — `UbisamBase.Core.Charting.ChartTheme` (LiveCharts2) ## 4. 차트 — `UbisamBase.Core.Charting.ChartTheme` (LiveCharts2)
@ -505,6 +518,34 @@ public partial class DeckViewModel : ObservableObject, ICloseGuard
- **비주얼 트리를 거슬러 올라가 Border의 Padding을 직접 0으로 만들지 말 것.** 플랫폼 레이아웃이 - **비주얼 트리를 거슬러 올라가 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 컨벤션 ## 9. 테마 · UI 컨벤션
- `Ubisam.Brush.*`, `Ubisam.Style.*`로 시작하는 리소스가 다크/라이트 테마 전체를 정의 - `Ubisam.Brush.*`, `Ubisam.Style.*`로 시작하는 리소스가 다크/라이트 테마 전체를 정의
@ -663,11 +704,18 @@ Reference를 추가해야 한다.
| 경로 | 내용 | | 경로 | 내용 |
| --- | --- | | --- | --- |
| `D:\UbisamConfig\Settings\` | 설정 클래스별 JSON | | `D:\UbisamConfig\Settings\` | 설정 클래스별 JSON |
| `D:\UbisamConfig\Settings\ShellWindowPlacement.json` | 셸 창의 마지막 모양(전체화면 여부·창모드 자리) |
| `D:\UbisamConfig\Log\` | 날짜별 로그 (`yyyyMMdd.log`) | | `D:\UbisamConfig\Log\` | 날짜별 로그 (`yyyyMMdd.log`) |
| `D:\UbisamConfig\ScreenSaver\` | 화면 잠금 시 마지막 아이디/비밀번호 | | `D:\UbisamConfig\ScreenSaver\` | 화면 잠금 시 마지막 아이디/비밀번호 |
여러 프로그램이 이 플랫폼으로 만들어져도 전부 `D:\UbisamConfig` 한 곳에 모입니다. 여러 프로그램이 이 플랫폼으로 만들어져도 전부 `D:\UbisamConfig` 한 곳에 모입니다.
**D 드라이브가 없는 PC** — `D:\UbisamConfig` 를 만들 수 없으면(드라이브가 없거나, 같은 이름 파일이
있거나, 권한이 없으면) `%LOCALAPPDATA%\UbisamConfig` 에 씁니다(`ConfigLocation`). 어디에 쓰는지는
로그 첫 줄에 남습니다. 두 자리 모두 못 쓰면 **까닭을 적은 안내창**을 띄우고 끝냅니다 — 예전에는
창 없는 프로세스만 남아 "켜지지 않는다"로 보였습니다. 설정 폴더 자리는 `UbisamAppBuilder.ConfigRoot`
로 읽습니다(상수가 아니라 켤 때 정해지는 값이라 하드코딩하지 말 것).
--- ---
## 12. 플랫폼 자동 업데이트 (깃) ## 12. 플랫폼 자동 업데이트 (깃)

View File

@ -0,0 +1,65 @@
using System;
using System.IO;
namespace UbisamBase.Core.Bootstrap;
/// <summary>
/// 설정 · 로그 · 화면 잠금 기록을 둘 자리를 정한다.
///
/// 보통은 <see cref="Preferred"/>(<c>D:\UbisamConfig</c>) 다 — 여러 프로그램이 이 플랫폼으로 만들어져도
/// 한 곳에 모인다. <b>D 드라이브가 없거나 그 자리에 폴더를 만들 수 없으면</b> <see cref="Fallback"/>
/// (<c>%LOCALAPPDATA%\UbisamConfig</c>)에 쓴다(MergeHub 11차 B-2, 2026-09-22 사용자 결정).
///
/// 예전에는 D:\ 고정이었고 폴더를 못 만들 때 감싼 곳이 없었다. 셸이 켜지다 말고 공용 예외 창을 띄운 뒤
/// <b>창 없는 프로세스만 남았다</b>(D:\UbisamConfig 자리에 같은 이름 파일을 두어 같은 줄에서 재현).
/// D 드라이브가 있는 PC 에서는 예전과 똑같다.
/// </summary>
public static class ConfigLocation
{
/// <summary>늘 먼저 쓰는 자리.</summary>
public const string Preferred = @"D:\UbisamConfig";
/// <summary>먼저 쓰는 자리를 못 쓸 때의 자리 - Windows 계정마다 따로다.</summary>
public static string Fallback =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UbisamConfig");
/// <summary>
/// 쓸 자리를 고른다.
/// </summary>
/// <param name="tryCreate">자리를 만들어 보고, 되면 null · 안 되면 까닭을 돌려준다(<see cref="TryCreate"/>).</param>
/// <returns>고른 자리와, 먼저 쓰는 자리를 못 쓴 까닭(썼으면 null).</returns>
/// <exception cref="IOException">두 자리 모두 못 쓸 때 - 두 까닭을 함께 적는다.</exception>
public static (string Root, string? FallbackReason) Choose(string preferred, string fallback, Func<string, string?> 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}");
}
/// <summary>
/// 그 자리에 Settings 폴더를 만들어 본다. 되면 null, 안 되면 까닭.
/// 드라이브가 없으면 DirectoryNotFoundException, 같은 이름 파일이 있으면 IOException, 권한이 없으면 UnauthorizedAccessException 이다.
/// </summary>
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;
}
}
/// <summary>다른 자리를 쓰게 됐을 때 로그에 남길 글.</summary>
public static string FallbackNote(string preferred, string used, string reason) =>
$"{preferred} 를 쓸 수 없어 {used} 에 설정 · 로그를 둡니다 ({reason})";
}

View File

@ -0,0 +1,31 @@
using System;
namespace UbisamBase.Core.Bootstrap;
/// <summary>
/// 셸이 사람에게 보이는 실패 글(MergeHub 11차 B-2).
///
/// 예전 글은 켜지다 만 때에도 "프로그램은 계속 쓸 수 있습니다" · "로그 파일에 남았습니다" 라고 했다.
/// 본 창이 안 떴으니 쓸 것이 없었고, 로그는 그보다 뒤에 준비되므로 아무 데도 안 남았다.
/// 그래서 <b>켜는 중 실패</b>와 <b>쓰는 중 실패</b>를 나누고, 로그는 준비됐을 때만 자리를 댄다.
/// </summary>
public static class StartupMessages
{
/// <summary>본 창이 뜨기 전에 실패했을 때. 이 글을 보인 뒤 프로그램을 끝낸다.</summary>
public static string StartFailed(string reason, string? logDirectory) =>
"프로그램을 켜지 못해 끝냅니다." + Environment.NewLine +
Environment.NewLine +
reason + LogHint(logDirectory);
/// <summary>쓰는 도중 한 동작이 실패했을 때. 프로그램은 계속 돈다.</summary>
public static string ActionFailed(string reason, string? logDirectory) =>
"작업 중 문제가 생겨 이 동작을 멈췄습니다. 프로그램은 계속 쓸 수 있습니다." + Environment.NewLine +
Environment.NewLine +
reason + LogHint(logDirectory);
/// <summary>로그가 준비됐을 때만 자리를 댄다 - 못 남긴 것을 남았다고 하지 않는다.</summary>
public static string LogHint(string? logDirectory) =>
string.IsNullOrEmpty(logDirectory)
? string.Empty
: Environment.NewLine + Environment.NewLine + "자세한 내용은 " + logDirectory + " 아래 로그 파일에 남았습니다.";
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Windows; using System.Windows;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -24,17 +24,32 @@ namespace UbisamBase.Core.Bootstrap;
public static class UbisamAppBuilder public static class UbisamAppBuilder
{ {
/// <summary> /// <summary>
/// 설정 JSON(Settings 폴더)이 저장되는 고정 위치. exe가 어디서 실행되든, 여러 프로그램이 /// 설정 JSON(Settings 폴더) · 로그가 저장되는 자리. 여러 프로그램이 이 플랫폼으로 만들어져도 한 곳에 모인다.
/// 이 플랫폼으로 만들어져도 항상 D:\UbisamConfig\Settings\ 아래에 모인다. 폴더가 없으면 자동 생성된다. ///
/// 보통은 <c>D:\UbisamConfig</c> 다. <b>D 드라이브가 없거나 거기에 폴더를 만들 수 없으면</b>
/// <c>%LOCALAPPDATA%\UbisamConfig</c> 를 쓴다(<see cref="ConfigLocation"/>).
/// <b>상수가 아니라 속성</b>이다 - 상수는 소비 프로그램에 글자로 박혀, 다시 빌드하지 않으면 옛 자리를 계속 본다.
/// <see cref="Run"/> 전에는 <see cref="ConfigLocation.Preferred"/> 다.
/// </summary> /// </summary>
public const string ConfigRoot = @"D:\UbisamConfig"; public static string ConfigRoot { get; private set; } = ConfigLocation.Preferred;
/// <summary><see cref="ConfigLocation.Preferred"/> 를 못 쓰고 다른 자리를 쓴 까닭. 썼으면 null.</summary>
public static string? ConfigFallbackReason { get; private set; }
public static IServiceProvider Run(string basePath, Action<IServiceCollection>? configureServices = null) public static IServiceProvider Run(string basePath, Action<IServiceCollection>? configureServices = null)
{ {
var uiSettings = new UiSettingsService(); // 자리를 먼저 고르고 로그부터 준비한다 - 뒤에서 무엇이 실패해도 까닭이 파일에 남는다.
uiSettings.Initialize(ConfigRoot); (ConfigRoot, ConfigFallbackReason) = ConfigLocation.Choose(
ConfigLocation.Preferred, ConfigLocation.Fallback, ConfigLocation.TryCreate);
LogService.Current.Initialize(ConfigRoot); 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(); ButtonClickLogger.Install();
FullscreenHotkeyInstaller.Install(); FullscreenHotkeyInstaller.Install();

View File

@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Windows; using System.Windows;
@ -29,6 +29,9 @@ public partial class LockConfirmDialog : Window
{ {
WindowStartupLocation = WindowStartupLocation.CenterScreen; WindowStartupLocation = WindowStartupLocation.CenterScreen;
} }
// 전체화면 셸 위에서는 CenterOwner 가 어긋난다 - 뜬 뒤 실제 자리로 맞춘다.
ContentRendered += (_, _) => Shell.OwnerCentering.CenterOnOwner(this);
} }
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove(); private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();

View File

@ -0,0 +1,20 @@
namespace UbisamBase.Core.Messaging;
/// <summary>
/// 세 갈래로 묻는 창에서 사용자가 고른 것(<see cref="MessageUtil.ShowChoice"/>).
///
/// 예/아니오만으로는 가를 수 없는 자리에 쓴다 - "저장하고 계속 · 버리고 계속 · 취소" 처럼
/// <b>계속할지</b> 와 <b>저장할지</b> 를 한 번에 물어야 할 때. 단추 글은 부르는 쪽이 정하고,
/// 여기서는 세 자리(주 동작 · 보조 동작 · 그만두기)만 가른다.
/// </summary>
public enum DialogChoice
{
/// <summary>그만둔다. Esc · 창 닫기도 이것이다.</summary>
Cancel,
/// <summary>주 동작(오른쪽 채운 단추).</summary>
Primary,
/// <summary>보조 동작(가운데 단추).</summary>
Secondary
}

View File

@ -1,4 +1,4 @@
<Window x:Class="UbisamBase.Core.Messaging.MessageDialog" <Window x:Class="UbisamBase.Core.Messaging.MessageDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="420" Width="420"
@ -43,6 +43,14 @@
BorderThickness="0,1,0,0" BorderThickness="0,1,0,0"
Padding="16,12"> Padding="16,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<!-- 세 갈래로 물을 때만 보인다(예: 저장하고 계속 · 버리고 계속 · 취소). 왼쪽이 "그만두기" 쪽. -->
<Button x:Name="TertiaryButton"
Content="취소"
MinWidth="84"
Padding="14,7"
Margin="0,0,8,0"
Visibility="Collapsed"
Click="Tertiary_Click"/>
<Button x:Name="SecondaryButton" <Button x:Name="SecondaryButton"
Content="아니오" Content="아니오"
MinWidth="84" MinWidth="84"

View File

@ -1,4 +1,4 @@
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
@ -19,6 +19,9 @@ public partial class MessageDialog : Window
private bool result; private bool result;
/// <summary>세 갈래로 물었을 때 무엇을 골랐는지. 두 갈래(예/아니오)에서는 쓰지 않는다.</summary>
private DialogChoice choice = DialogChoice.Cancel;
private MessageDialog(string title, string message, DialogKind kind, bool showSecondary) private MessageDialog(string title, string message, DialogKind kind, bool showSecondary)
{ {
InitializeComponent(); InitializeComponent();
@ -49,6 +52,9 @@ public partial class MessageDialog : Window
{ {
WindowStartupLocation = WindowStartupLocation.CenterScreen; WindowStartupLocation = WindowStartupLocation.CenterScreen;
} }
// CenterOwner 는 본 창이 전체화면일 때 "복원 자리" 를 기준으로 삼아 어긋난다 - 뜬 뒤 실제 자리로 맞춘다.
ContentRendered += (_, _) => Shell.OwnerCentering.CenterOnOwner(this);
} }
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove(); private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
@ -56,12 +62,21 @@ public partial class MessageDialog : Window
private void Primary_Click(object sender, RoutedEventArgs e) private void Primary_Click(object sender, RoutedEventArgs e)
{ {
result = true; result = true;
choice = DialogChoice.Primary;
DialogResult = true; DialogResult = true;
} }
private void Secondary_Click(object sender, RoutedEventArgs e) private void Secondary_Click(object sender, RoutedEventArgs e)
{ {
result = false; result = false;
choice = DialogChoice.Secondary;
DialogResult = false;
}
private void Tertiary_Click(object sender, RoutedEventArgs e)
{
result = false;
choice = DialogChoice.Cancel;
DialogResult = false; DialogResult = false;
} }
@ -73,6 +88,7 @@ public partial class MessageDialog : Window
} }
result = false; result = false;
choice = DialogChoice.Cancel; // Esc 는 늘 "그만두기"
DialogResult = false; DialogResult = false;
e.Handled = true; e.Handled = true;
} }
@ -89,4 +105,20 @@ public partial class MessageDialog : Window
dialog.ShowDialog(); dialog.ShowDialog();
return dialog.result; return dialog.result;
} }
/// <summary>
/// 세 갈래로 묻는다. 예/아니오로 가를 수 없는 자리에 쓴다 - 대표적으로 "저장하고 계속 · 버리고 계속 · 취소".
/// 예/아니오 두 갈래로 물으면 사용자가 [아니오] 를 "버리고 계속" 으로 읽을지 "그만두기" 로 읽을지 알 수 없다.
/// 단추 글은 부르는 쪽이 정한다. Esc · 창 닫기는 늘 <see cref="DialogChoice.Cancel"/>.
/// </summary>
public static DialogChoice ShowChoice(string title, string message, string primary, string secondary, string cancel)
{
var dialog = new MessageDialog(title, message, DialogKind.Question, showSecondary: true);
dialog.PrimaryButton.Content = primary;
dialog.SecondaryButton.Content = secondary;
dialog.TertiaryButton.Content = cancel;
dialog.TertiaryButton.Visibility = Visibility.Visible;
dialog.ShowDialog();
return dialog.choice;
}
} }

View File

@ -1,4 +1,4 @@
using UbisamBase.Core.Logging; using UbisamBase.Core.Logging;
namespace UbisamBase.Core.Messaging; namespace UbisamBase.Core.Messaging;
@ -12,6 +12,14 @@ public static class MessageUtil
public static bool ShowYesNo(string title, string message) => MessageDialog.ShowYesNo(title, message); public static bool ShowYesNo(string title, string message) => MessageDialog.ShowYesNo(title, message);
/// <summary>
/// 세 갈래로 묻는다 - "저장하고 계속 · 버리고 계속 · 취소" 처럼 예/아니오로 가를 수 없는 자리.
/// 단추 글은 부르는 쪽이 정한다. Esc · 창 닫기는 <see cref="DialogChoice.Cancel"/>.
/// </summary>
public static DialogChoice ShowChoice(string title, string message,
string primary = "예", string secondary = "아니오", string cancel = "취소")
=> MessageDialog.ShowChoice(title, message, primary, secondary, cancel);
public static void ShowSaveSuccessDialog() => ShowInfo("저장", "저장되었습니다."); public static void ShowSaveSuccessDialog() => ShowInfo("저장", "저장되었습니다.");
public static void ShowSaveFailedDialog() => ShowError("저장 실패", "저장하지 못했습니다."); public static void ShowSaveFailedDialog() => ShowError("저장 실패", "저장하지 못했습니다.");

View File

@ -1,4 +1,4 @@
namespace UbisamBase.Core.Modules; namespace UbisamBase.Core.Modules;
/// <summary> /// <summary>
/// AddMain에 넘길 수 있는 아이콘 목록. Themes/Icons.xaml에 등록된 Geometry 리소스와 이름이 1:1로 대응한다 /// AddMain에 넘길 수 있는 아이콘 목록. Themes/Icons.xaml에 등록된 Geometry 리소스와 이름이 1:1로 대응한다
@ -41,5 +41,8 @@ public enum Icon
Desktop, Desktop,
Camera, Camera,
Fullscreen, Fullscreen,
FullscreenExit FullscreenExit,
/// <summary>탭 줄 접기.</summary>
CollapseNav
} }

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -33,6 +33,17 @@ public sealed class SettingsService : ISettingsService
public T RegisterSettings<T>(string key, T defaultInstance) where T : class public T RegisterSettings<T>(string key, T defaultInstance) where T : class
{ {
// 같은 키를 두 번 등록하면 이미 있는 객체를 그대로 돌려준다.
//
// 그러지 않으면 인스턴스가 갈라진다 - 소비 프로그램 코드가 들고 쓰는 객체와
// 설정 화면이 고치는 객체가 서로 달라져서, 저장을 눌러도 프로그램 동작은 그대로이고
// 다시 켜야만 반영된다. 코드에서 설정을 쓰면서 설정 탭도 노출하는 흔한 구조에서
// 반드시 걸리는 문제다.
if (items.TryGetValue(key, out var registered) && registered is T existing)
{
return existing;
}
var path = FilePath(key); var path = FilePath(key);
T instance = defaultInstance; T instance = defaultInstance;

View File

@ -0,0 +1,60 @@
using System;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace UbisamBase.Core.Settings;
/// <summary>
/// <c>UiSettingsService.json</c> 에서 <b>한 항목만</b> 읽고 고쳐 쓴다(MergeHub 11차 F-4).
///
/// 아래 탭 줄 접기는 누를 때마다 바로 기억해야 한다. 그런데 <see cref="UiSettingsService.Save"/> 는
/// 지금 메모리의 값을 통째로 쓰므로, 설정 화면에서 바꾸고 아직 [저장] 하지 않은 테마 · 배율까지 함께 저장된다.
/// 그래서 파일의 다른 값(모르는 항목 포함)은 그대로 두고 이 항목만 바꾼다.
/// </summary>
public static class UiSettingsFile
{
public const string NavCollapsedKey = "NavCollapsed";
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
/// <summary>아래 탭 줄을 접어 둔 상태인지. 항목이 없거나 글이 망가졌으면 펼침(false).</summary>
public static bool ReadNavCollapsed(string? json)
{
try
{
return Parse(json) is JsonObject obj &&
obj[NavCollapsedKey] is JsonValue value &&
value.TryGetValue(out bool collapsed) && collapsed;
}
catch (Exception e) when (e is JsonException || e is InvalidOperationException || e is FormatException)
{
return false;
}
}
/// <summary>
/// 접기 항목만 바꾼 글. 글이 없거나 망가졌으면 그 항목만 든 새 글 - 망가진 파일은 어차피
/// <see cref="UiSettingsService.Initialize"/> 가 무시하고 기본값을 쓴다.
/// </summary>
public static string WithNavCollapsed(string? json, bool collapsed)
{
JsonObject obj;
try
{
obj = Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
obj = new JsonObject();
}
obj[NavCollapsedKey] = collapsed;
return obj.ToJsonString(Indented);
}
private static JsonNode? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
return JsonNode.Parse(json!.TrimStart(''));
}
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Windows; using System.Windows;
@ -65,6 +65,11 @@ public partial class UiSettingsService : ObservableObject
/// <summary>XAML에서 쓰기 편하게 뒤집어 둔 값 — 상단 바를 보여야 하면 true.</summary> /// <summary>XAML에서 쓰기 편하게 뒤집어 둔 값 — 상단 바를 보여야 하면 true.</summary>
public bool TopBarVisible => !TopBarCollapsed; public bool TopBarVisible => !TopBarCollapsed;
// 탭 줄(네비게이션)을 접어 두었는지. 상단 바와 같은 까닭으로 "접힘"을 true 로 둔다 —
// 이 항목이 없는 이전 버전 json 은 false(펼침)로 읽혀 지금까지와 같은 모습이 된다.
[ObservableProperty]
private bool navCollapsed;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private string? filePath; private string? filePath;
@ -90,6 +95,7 @@ public partial class UiSettingsService : ObservableObject
NavPosition = snapshot.NavPosition; NavPosition = snapshot.NavPosition;
ToastPosition = snapshot.ToastPosition; ToastPosition = snapshot.ToastPosition;
TopBarCollapsed = snapshot.TopBarCollapsed; TopBarCollapsed = snapshot.TopBarCollapsed;
NavCollapsed = snapshot.NavCollapsed;
} }
} }
catch catch
@ -119,11 +125,35 @@ public partial class UiSettingsService : ObservableObject
ChromeScale = ChromeScale, ChromeScale = ChromeScale,
NavPosition = NavPosition, NavPosition = NavPosition,
ToastPosition = ToastPosition, ToastPosition = ToastPosition,
TopBarCollapsed = TopBarCollapsed TopBarCollapsed = TopBarCollapsed,
NavCollapsed = NavCollapsed
}; };
File.WriteAllText(filePath, JsonSerializer.Serialize(snapshot, JsonOptions)); File.WriteAllText(filePath, JsonSerializer.Serialize(snapshot, JsonOptions));
} }
/// <summary>
/// 탭 줄 접힘만 파일에 바로 쓴다. 접고 펴는 것은 누를 때마다 기억해야 하는데,
/// <see cref="Save"/> 는 지금 메모리 값을 통째로 쓰므로 설정 화면에서 바꾸고 아직 [저장] 하지 않은
/// 테마 · 배율까지 함께 써진다. 그래서 이 항목만 고쳐 쓴다(<see cref="UiSettingsFile"/>).
/// </summary>
public void SaveNavCollapsed()
{
if (filePath == null)
{
return;
}
try
{
var json = File.Exists(filePath) ? File.ReadAllText(filePath) : null;
File.WriteAllText(filePath, UiSettingsFile.WithNavCollapsed(json, NavCollapsed));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// 접힘 상태는 덤이다 - 못 써도 프로그램은 그대로 돈다.
}
}
/// <summary>디스크에 저장된 값으로 다시 불러온다 — UI Setting 화면의 "새로고침" 버튼이 호출한다.</summary> /// <summary>디스크에 저장된 값으로 다시 불러온다 — UI Setting 화면의 "새로고침" 버튼이 호출한다.</summary>
public void Reload() public void Reload()
{ {
@ -142,6 +172,7 @@ public partial class UiSettingsService : ObservableObject
NavPosition = snapshot.NavPosition; NavPosition = snapshot.NavPosition;
ToastPosition = snapshot.ToastPosition; ToastPosition = snapshot.ToastPosition;
TopBarCollapsed = snapshot.TopBarCollapsed; TopBarCollapsed = snapshot.TopBarCollapsed;
NavCollapsed = snapshot.NavCollapsed;
} }
} }
catch catch
@ -159,6 +190,8 @@ public partial class UiSettingsService : ObservableObject
public NavPosition NavPosition { get; set; } public NavPosition NavPosition { get; set; }
public ToastPosition ToastPosition { get; set; } public ToastPosition ToastPosition { get; set; }
public bool TopBarCollapsed { get; set; } public bool TopBarCollapsed { get; set; }
public bool NavCollapsed { get; set; }
} }
private static void ApplyTheme(ThemeMode mode) private static void ApplyTheme(ThemeMode mode)

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Windows; using System.Windows;
@ -92,6 +92,22 @@ public static class Displays
return new Rect(device.Left / scaleX, device.Top / scaleY, device.Width / scaleX, device.Height / scaleY); return new Rect(device.Left / scaleX, device.Top / scaleY, device.Width / scaleX, device.Height / scaleY);
} }
/// <summary>
/// 모든 모니터의 작업 영역(작업표시줄 제외)을 Win32 좌표 그대로. 주 모니터가 맨 앞이다.
/// 셸이 기억한 창 자리를 화면 안으로 되돌릴 때 쓴다(<see cref="ShellPlacementMath.FitToMonitors"/>) -
/// 그 계산은 창 손잡이에서 읽은 Win32 좌표를 다루므로 DIP 로 바꾸지 않는다.
/// </summary>
internal static IReadOnlyList<Rect> WorkAreasDevice()
{
var areas = new List<Rect>();
foreach (var monitor in GetMonitors())
{
areas.Add(monitor.Work);
}
return areas;
}
private readonly struct MonitorInfo private readonly struct MonitorInfo
{ {
public MonitorInfo(IntPtr handle, Rect work) public MonitorInfo(IntPtr handle, Rect work)

View File

@ -0,0 +1,82 @@
using System.Windows;
using System.Windows.Media;
namespace UbisamBase.Core.Shell;
/// <summary>
/// 덮는 창(확인 · 잠금 확인 같은 대화상자)을 <b>본 창의 실제 자리</b> 가운데에 놓는다.
///
/// WPF 의 <c>CenterOwner</c> 는 본 창이 최대화면 그 창의 "복원 자리" 를 기준으로 삼는다. 셸 창은 전체화면(kiosk)에서
/// 작업 표시줄까지 덮으므로 그 둘이 어긋나, 확인 창이 세로로 작업 표시줄 절반만큼 위에 떴다.
/// 창 손잡이에서 실제 사각형을 읽어 맞춘다 - 보통 창에서는 <c>CenterOwner</c> 와 같은 자리다.
/// 본 창을 못 찾으면 아무것도 하지 않는다(WPF 가 정한 자리를 그대로 둔다).
/// </summary>
internal static class OwnerCentering
{
/// <summary>창이 떠서 크기가 정해진 뒤 한 번 부른다(예: <c>ContentRendered</c>).</summary>
public static void CenterOnOwner(Window dialog)
{
if (dialog is null)
{
return;
}
var owner = FindOwner(dialog);
if (owner is null)
{
return;
}
var ownerRect = MonitorHelper.GetWindowBounds(owner);
if (ownerRect.IsEmpty || ownerRect.Width <= 0 || ownerRect.Height <= 0)
{
return;
}
var scale = VisualTreeHelper.GetDpi(dialog).DpiScaleX;
if (scale <= 0)
{
return;
}
var width = dialog.ActualWidth > 0 ? dialog.ActualWidth : dialog.Width;
var height = dialog.ActualHeight > 0 ? dialog.ActualHeight : dialog.Height;
if (double.IsNaN(width) || double.IsNaN(height) || width <= 0 || height <= 0)
{
return;
}
// 본 창 사각형은 Win32 좌표, 창 자리(Left · Top)는 DIP 다. 배율로 한 번 나눠 맞춘다.
dialog.Left = (ownerRect.Left + (ownerRect.Width - (width * scale)) / 2) / scale;
dialog.Top = (ownerRect.Top + (ownerRect.Height - (height * scale)) / 2) / scale;
}
/// <summary>맞출 본 창. 켤 때 셸보다 먼저 뜨는 창도 있어 Owner · MainWindow · 떠 있는 셸 순으로 찾는다.</summary>
private static Window? FindOwner(Window dialog)
{
if (dialog.Owner is { IsVisible: true } owner && !ReferenceEquals(owner, dialog))
{
return owner;
}
if (Application.Current?.MainWindow is { IsVisible: true } main && !ReferenceEquals(main, dialog))
{
return main;
}
if (Application.Current is null)
{
return null;
}
foreach (Window window in Application.Current.Windows)
{
if (window is ShellWindow { IsVisible: true } shell && !ReferenceEquals(shell, dialog))
{
return shell;
}
}
return null;
}
}

View File

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
namespace UbisamBase.Core.Shell;
/// <summary>
/// 셸 창의 마지막 모드(전체화면 · 창모드)와 창모드일 때의 자리 · 크기.
/// <c>{설정 폴더}\Settings\ShellWindowPlacement.json</c> 에 둔다.
///
/// UI 설정 파일과 따로 두는 까닭 — UI 설정은 [저장] 을 눌러야 쓰는데 창 자리는 닫을 때마다 쓴다.
/// 한 파일에 담으면 저장하지 않은 UI 설정까지 함께 써진다.
/// 파일이 없거나 깨졌으면 <b>지금까지처럼 전체화면</b>으로 켠다.
/// </summary>
internal sealed class ShellPlacement
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
/// <summary>닫을 때 전체화면(kiosk)이었는지.</summary>
public bool Fullscreen { get; set; } = true;
/// <summary>창모드일 때의 자리 · 크기(Win32 좌표). 한 번도 창모드로 쓰지 않았으면 0 이다.</summary>
public double Left { get; set; }
public double Top { get; set; }
public double Width { get; set; }
public double Height { get; set; }
[JsonIgnore]
public bool HasWindowRect => Width > 0 && Height > 0;
[JsonIgnore]
public Rect WindowRect => new Rect(Left, Top, Width, Height);
public static string? PathIn(string? configRoot) =>
string.IsNullOrWhiteSpace(configRoot) ? null : Path.Combine(configRoot, "Settings", "ShellWindowPlacement.json");
/// <summary>읽는다. 없거나 깨졌으면 null - 부르는 쪽이 전체화면으로 켠다.</summary>
public static ShellPlacement? Load(string? path)
{
if (path is null || !File.Exists(path))
{
return null;
}
try
{
return JsonSerializer.Deserialize<ShellPlacement>(File.ReadAllText(path), JsonOptions);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
return null; // 깨진 파일 때문에 못 켜지면 안 된다
}
}
/// <summary>쓴다. 못 써도 조용히 넘어간다 - 창 자리는 덤이다.</summary>
public void Save(string? path)
{
if (path is null)
{
return;
}
try
{
var folder = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder))
{
Directory.CreateDirectory(folder);
}
File.WriteAllText(path, JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
}
/// <summary>
/// 창 자리 계산. 화면 없이 검사할 수 있게 순수 함수로 둔다.
/// </summary>
internal static class ShellPlacementMath
{
/// <summary>창모드 최소 크기(Win32 좌표). 이보다 작게 기억돼 있어도 이만큼은 띄운다.</summary>
public const double MinWidth = 900;
public const double MinHeight = 600;
/// <summary>
/// 기억한 창 자리를 켤 때 놓을 자리. <b>가장 많이 겹치는 모니터</b>의 작업 영역 안으로 밀고, 모니터보다 크면 줄인다.
///
/// 모니터를 떼거나 해상도 · 배율이 바뀌면 기억한 자리가 화면 밖일 수 있다 - 그러면 창을 아예 못 잡는다.
/// 겹치는 모니터가 없으면(예: 없어진 모니터에 있던 창) 주 모니터 가운데로 보낸다.
/// </summary>
public static Rect FitToMonitors(Rect saved, IReadOnlyList<Rect> workAreas)
{
if (workAreas is null || workAreas.Count == 0)
{
return saved;
}
var best = workAreas[0];
var bestOverlap = -1.0;
foreach (var work in workAreas)
{
var overlap = Overlap(saved, work);
if (overlap > bestOverlap)
{
bestOverlap = overlap;
best = work;
}
}
// 어느 모니터와도 겹치지 않는다 - 첫 모니터(주 모니터) 가운데로.
if (bestOverlap <= 0)
{
var w0 = Math.Min(Math.Max(saved.Width, MinWidth), best.Width);
var h0 = Math.Min(Math.Max(saved.Height, MinHeight), best.Height);
return new Rect(best.Left + (best.Width - w0) / 2, best.Top + (best.Height - h0) / 2, w0, h0);
}
var width = Math.Min(Math.Max(saved.Width, MinWidth), best.Width);
var height = Math.Min(Math.Max(saved.Height, MinHeight), best.Height);
var left = Clamp(saved.Left, best.Left, best.Right - width);
var top = Clamp(saved.Top, best.Top, best.Bottom - height);
return new Rect(left, top, width, height);
}
/// <summary>
/// 최대화한 창이 그 모니터에 꼭 맞는지. 다르면 부르는 쪽이 모니터 크기로 다시 놓는다.
///
/// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 화면 밖으로 나갔다
/// (재현: 3840×2160 모니터에서 5120×2186). 최대화된 뒤 한 번 다시 놓아 바로잡는다.
/// 이미 맞으면 하지 않는다 - 다시 놓기가 되풀이되지 않게.
/// </summary>
public static bool NeedsMaximizeFix(Rect window, Rect monitor) =>
monitor.Width > 0 && monitor.Height > 0 &&
(Math.Abs(window.Left - monitor.Left) > 0.5 || Math.Abs(window.Top - monitor.Top) > 0.5 ||
Math.Abs(window.Width - monitor.Width) > 0.5 || Math.Abs(window.Height - monitor.Height) > 0.5);
private static double Overlap(Rect a, Rect b)
{
var width = Math.Min(a.Right, b.Right) - Math.Max(a.Left, b.Left);
var height = Math.Min(a.Bottom, b.Bottom) - Math.Max(a.Top, b.Top);
return width <= 0 || height <= 0 ? 0 : width * height;
}
private static double Clamp(double value, double min, double max) =>
max < min ? min : Math.Max(min, Math.Min(value, max));
}

View File

@ -1,4 +1,4 @@
<Window x:Class="UbisamBase.Core.Shell.ShellWindow" <Window x:Class="UbisamBase.Core.Shell.ShellWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:messaging="clr-namespace:UbisamBase.Core.Messaging" xmlns:messaging="clr-namespace:UbisamBase.Core.Messaging"
@ -142,6 +142,47 @@
</Border> </Border>
<!-- 네비게이션: 기본은 하단(NavPosition.Bottom). 좌/우 전환은 코드비하인드가 처리 --> <!-- 네비게이션: 기본은 하단(NavPosition.Bottom). 좌/우 전환은 코드비하인드가 처리 -->
<!-- 탭 줄을 접었을 때 남는 얇은 띠. 누르거나 F11 로 다시 편다.
NavList 보다 먼저 두어야 같은 쪽에 붙을 때 창 가장자리를 차지한다. 자리 · 방향은 ApplyNavPosition 이 정한다. -->
<Button x:Name="NavExpandStrip"
DockPanel.Dock="Bottom"
Visibility="Collapsed"
MinHeight="0" MinWidth="0" Padding="0"
Click="NavExpandStrip_Click"
Cursor="Hand"
ToolTip="탭 줄 펴기 (F11)"
Focusable="False">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
<StackPanel x:Name="NavExpandContent" Orientation="Horizontal">
<Path x:Name="NavExpandArrow"
Data="M1,7 L6,2 L11,7"
Stroke="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"
StrokeThickness="1.5" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Width="12" Height="9" Stretch="None" VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="NavExpandArrowTurn" Angle="0"/>
</Path.RenderTransform>
</Path>
<TextBlock x:Name="NavExpandLabel" Text="탭 펴기 · F11" FontSize="10" Margin="6,0,0,0"
Foreground="{DynamicResource Ubisam.Brush.Sidebar.Foreground}" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<ListBox x:Name="NavList" <ListBox x:Name="NavList"
DockPanel.Dock="Bottom" DockPanel.Dock="Bottom"
Visibility="{Binding Path=(shell:PlatformChrome.IsVisible), Converter={StaticResource BoolToVis}}" Visibility="{Binding Path=(shell:PlatformChrome.IsVisible), Converter={StaticResource BoolToVis}}"

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -30,6 +30,17 @@ public partial class ShellWindow : Window
private bool closeApproved; private bool closeApproved;
private bool closeAsking; private bool closeAsking;
/// <summary>창모드일 때의 마지막 자리(Win32 좌표). 닫을 때 이 값을 기억한다.</summary>
private Rect lastWindowedRect;
/// <summary>트레이 아이콘. 최소화해서 트레이로 내려갈 때 처음 만든다.</summary>
private TrayIcon? tray;
private ContextMenu? trayMenu;
/// <summary>트레이로 내려가기 바로 전의 창 모양. 되살릴 때 그대로 돌려놓는다.</summary>
private WindowState stateBeforeTray = WindowState.Normal;
public ShellWindow(ShellViewModel viewModel) public ShellWindow(ShellViewModel viewModel)
{ {
InitializeComponent(); InitializeComponent();
@ -44,6 +55,8 @@ public partial class ShellWindow : Window
Closed += (_, _) => PlatformChrome.FullBleedChanged -= OnFullBleedChanged; Closed += (_, _) => PlatformChrome.FullBleedChanged -= OnFullBleedChanged;
ApplyNavPosition(viewModel.UiSettings.NavPosition); ApplyNavPosition(viewModel.UiSettings.NavPosition);
ApplyToastPosition(viewModel.UiSettings.ToastPosition); ApplyToastPosition(viewModel.UiSettings.ToastPosition);
Loaded += (_, _) => ApplyNavCollapsed(viewModel.UiSettings.NavCollapsed);
PreviewKeyDown += ShellWindow_PreviewKeyDown;
// 하단 탭 바 = 등록된 실제 탭(MainTabs, 왼쪽에 모임) + "화면 잠금"/"바탕화면"/"종료" 같은 // 하단 탭 바 = 등록된 실제 탭(MainTabs, 왼쪽에 모임) + "화면 잠금"/"바탕화면"/"종료" 같은
// 동작형 항목(오른쪽 끝에 붙음, ShellStyles.xaml의 DockPanel.Dock=Right 트리거로 처리). // 동작형 항목(오른쪽 끝에 붙음, ShellStyles.xaml의 DockPanel.Dock=Right 트리거로 처리).
@ -56,7 +69,9 @@ public partial class ShellWindow : Window
// Shutdown()이 아니라 Close()다 — 그래야 ICloseGuard(저장 안 한 변경 확인)를 거친다. // Shutdown()이 아니라 Close()다 — 그래야 ICloseGuard(저장 안 한 변경 확인)를 거친다.
new NavActionItem("종료", UbisamBase.Core.Modules.Icon.Power, Close), new NavActionItem("종료", UbisamBase.Core.Modules.Icon.Power, Close),
new NavActionItem("바탕화면", UbisamBase.Core.Modules.Icon.Desktop, () => WindowState = WindowState.Minimized), new NavActionItem("바탕화면", UbisamBase.Core.Modules.Icon.Desktop, () => WindowState = WindowState.Minimized),
new NavActionItem("화면 잠금", UbisamBase.Core.Modules.Icon.Lock, () => LockConfirmDialog.ShowAndLock()) new NavActionItem("화면 잠금", UbisamBase.Core.Modules.Icon.Lock, () => LockConfirmDialog.ShowAndLock()),
// 탭 줄 접기 - 콘텐츠를 세로(또는 가로)로 더 보이게. 접으면 얇은 띠가 남고, 띠나 F11 로 다시 편다.
new NavActionItem("탭 접기", UbisamBase.Core.Modules.Icon.CollapseNav, () => SetNavCollapsed(true))
}; };
NavList.ItemsSource = composite; NavList.ItemsSource = composite;
@ -101,6 +116,8 @@ public partial class ShellWindow : Window
/// <summary>셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다.</summary> /// <summary>셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다.</summary>
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
SavePlacement();
tray?.Dispose();
base.OnClosed(e); base.OnClosed(e);
Application.Current?.Shutdown(); Application.Current?.Shutdown();
} }
@ -177,7 +194,177 @@ public partial class ShellWindow : Window
protected override void OnSourceInitialized(EventArgs e) protected override void OnSourceInitialized(EventArgs e)
{ {
base.OnSourceInitialized(e); base.OnSourceInitialized(e);
ApplyKiosk();
// 지난번에 창모드로 껐으면 그 자리 · 크기로, 아니면 지금까지처럼 전체화면으로 켠다.
var saved = ShellPlacement.Load(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
if (saved is { Fullscreen: false, HasWindowRect: true })
{
ApplyWindowed(saved.WindowRect);
}
else
{
ApplyKiosk();
}
// 창모드에서 최대화(Win+↑ 등)하면 배율이 다른 모니터에서 화면 밖으로 넘칠 수 있다 - 그때 바로잡는다.
StateChanged += (_, _) => { FitMaximizedToMonitor(); OnStateChanged(); };
DpiChanged += (_, _) => FitMaximizedToMonitor();
LocationChanged += (_, _) => RememberWindowedRect();
SizeChanged += (_, _) => RememberWindowedRect();
}
/// <summary>창모드로 떠 있는 동안의 자리를 들고 있는다. 닫을 때 이 값을 기억한다.</summary>
private void RememberWindowedRect()
{
// 트레이로 내려간 동안(감춰짐)에는 창이 화면 밖(-32000)에 있다 - 그 자리를 기억하면 되살릴 때 못 찾는다.
if (isKiosk || WindowState != WindowState.Normal || !IsVisible)
{
return;
}
var rect = MonitorHelper.GetWindowBounds(this);
if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0 || IsParked(rect))
{
return;
}
lastWindowedRect = rect;
}
/// <summary>
/// 최소화한 창이 잠깐 놓이는 자리(-32000, -32000)인지.
///
/// 최소화하는 동안 창이 먼저 저 자리로 옮겨지고 그 다음에 모양이 "최소화"로 바뀐다.
/// 그 틈에 자리를 기억해 버리면 되살릴 때 화면 밖이라 어느 모니터에도 걸치지 않아,
/// 창이 주 모니터 가운데 최소 크기로 쪼그라들었다.
/// </summary>
private static bool IsParked(Rect rect) => rect.Left <= -30000 || rect.Top <= -30000;
/// <summary>마지막 모드와 창모드 자리를 파일에 남긴다. 못 써도 그냥 넘어간다.</summary>
private void SavePlacement()
{
var placement = new ShellPlacement { Fullscreen = isKiosk };
if (lastWindowedRect.Width > 0 && lastWindowedRect.Height > 0)
{
placement.Left = lastWindowedRect.Left;
placement.Top = lastWindowedRect.Top;
placement.Width = lastWindowedRect.Width;
placement.Height = lastWindowedRect.Height;
}
placement.Save(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
}
/// <summary>
/// 최대화한 창이 그 모니터보다 크면 모니터 크기로 다시 놓는다.
///
/// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 잘렸다.
/// 이미 맞으면 아무것도 하지 않아 다시 놓기가 되풀이되지 않는다.
/// </summary>
private void FitMaximizedToMonitor()
{
if (WindowState != WindowState.Maximized)
{
return;
}
var monitor = MonitorHelper.GetBounds(this);
var window = MonitorHelper.GetWindowBounds(this);
if (window.IsEmpty || !ShellPlacementMath.NeedsMaximizeFix(window, monitor))
{
return;
}
WindowState = WindowState.Normal;
Left = monitor.Left;
Top = monitor.Top;
Width = monitor.Width;
Height = monitor.Height;
FitToMonitor(monitor);
}
/// <summary>
/// 최소화하면 작업 표시줄 대신 트레이(알림 영역)로 내려간다.
///
/// 창을 <see cref="Window.Hide"/> 로 감추므로 작업 표시줄 단추도 함께 사라진다 - 되살리는 길은 트레이 아이콘뿐이라
/// 아이콘을 먼저 올리고 나서 감춘다. [바탕화면] 단추로 내려도 같다.
/// </summary>
private void OnStateChanged()
{
if (WindowState != WindowState.Minimized)
{
// 최소화가 아닌 마지막 모양을 들고 있는다 - 트레이에서 되살릴 때 이 모양으로 돌아간다.
stateBeforeTray = WindowState;
return;
}
if (!IsVisible)
{
return;
}
EnsureTray().Show();
Hide();
}
/// <summary>트레이에서 창을 되살린다. 내려가기 전 모양(전체화면 · 최대화 · 창모드)으로 돌아간다.</summary>
private void RestoreFromTray()
{
Show();
if (isKiosk)
{
ApplyKiosk();
}
else if (stateBeforeTray == WindowState.Maximized)
{
WindowState = WindowState.Maximized;
FitMaximizedToMonitor();
}
else
{
ApplyWindowed(lastWindowedRect, fit: false);
}
Activate();
tray?.Hide();
}
private TrayIcon EnsureTray()
{
if (tray != null)
{
return tray;
}
tray = new TrayIcon(string.IsNullOrWhiteSpace(Title) ? "Ubisam" : Title);
tray.Opened += (_, _) => RestoreFromTray();
tray.MenuRequested += (_, _) => ShowTrayMenu();
return tray;
}
/// <summary>트레이 아이콘 오른쪽 단추 메뉴 - [열기] · [종료].</summary>
private void ShowTrayMenu()
{
if (trayMenu == null)
{
var open = new MenuItem { Header = "열기" };
open.Click += (_, _) => RestoreFromTray();
var quit = new MenuItem { Header = "종료" };
quit.Click += (_, _) =>
{
// 닫기 확인(ICloseGuard)을 거치려면 창이 떠 있어야 한다 - 되살린 뒤 평소처럼 닫는다.
RestoreFromTray();
Close();
};
trayMenu = new ContextMenu();
trayMenu.Items.Add(open);
trayMenu.Items.Add(quit);
}
trayMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
trayMenu.IsOpen = true;
} }
/// <summary>작업표시줄까지 덮는 전체화면. Topmost는 걸지 않는다 — 현장의 다른 경보/알림 창이 /// <summary>작업표시줄까지 덮는 전체화면. Topmost는 걸지 않는다 — 현장의 다른 경보/알림 창이
@ -228,19 +415,50 @@ public partial class ShellWindow : Window
/// <summary>이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와 /// <summary>이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와
/// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을 /// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을
/// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로.</summary> /// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로.</summary>
private void ApplyWindowed() private void ApplyWindowed() => ApplyWindowed(lastWindowedRect);
/// <summary>
/// 이동 · 크기 조절이 되는 창모드. 기억한 자리가 있으면 그 자리로 - 없으면 지금까지처럼 1200×760 가운데.
///
/// 기억한 자리는 <b>모니터 안으로</b> 되돌려 놓는다(<see cref="ShellPlacementMath.FitToMonitors"/>).
/// 모니터를 떼거나 해상도 · 배율이 바뀌면 그 자리가 화면 밖일 수 있어, 그대로 놓으면 창을 잡을 수 없다.
/// </summary>
/// <param name="fit">
/// 기억한 자리를 모니터 안으로 밀지. 켤 때는 참이다 - 지난번과 모니터 · 배율이 다를 수 있다.
/// 트레이에서 되살릴 때는 거짓 - 몇 초 전 그 자리이므로 그대로 놓아야 크기가 달라지지 않는다.
/// </param>
private void ApplyWindowed(Rect remembered, bool fit = true)
{ {
isKiosk = false; isKiosk = false;
ResizeMode = ResizeMode.CanResizeWithGrip; ResizeMode = ResizeMode.CanResizeWithGrip;
WindowState = WindowState.Normal; WindowState = WindowState.Normal;
Width = 1200;
Height = 760;
var work = SystemParameters.WorkArea; if (remembered.Width > 0 && remembered.Height > 0)
Left = work.Left + Math.Max(0, (work.Width - Width) / 2); {
Top = work.Top + Math.Max(0, (work.Height - Height) / 2); var target = fit ? ShellPlacementMath.FitToMonitors(remembered, Displays.WorkAreasDevice()) : remembered;
var actual = MonitorHelper.GetWindowBounds(this);
var scaleX = actual.IsEmpty || Width <= 0 ? 1 : actual.Width / Width;
var scaleY = actual.IsEmpty || Height <= 0 ? 1 : actual.Height / Height;
if (scaleX <= 0) scaleX = 1;
if (scaleY <= 0) scaleY = 1;
Width = target.Width / scaleX;
Height = target.Height / scaleY;
Left = target.Left / scaleX;
Top = target.Top / scaleY;
}
else
{
Width = 1200;
Height = 760;
var work = SystemParameters.WorkArea;
Left = work.Left + Math.Max(0, (work.Width - Width) / 2);
Top = work.Top + Math.Max(0, (work.Height - Height) / 2);
}
Topmost = false; Topmost = false;
RememberWindowedRect();
SetFullscreenButtonState(toWindowed: false); SetFullscreenButtonState(toWindowed: false);
} }
@ -330,6 +548,48 @@ public partial class ShellWindow : Window
{ {
ApplyToastPosition(((UiSettingsService)sender!).ToastPosition); ApplyToastPosition(((UiSettingsService)sender!).ToastPosition);
} }
else if (e.PropertyName == nameof(UiSettingsService.NavCollapsed))
{
ApplyNavCollapsed(((UiSettingsService)sender!).NavCollapsed);
}
}
/// <summary>탭 줄을 접거나 편다. 누를 때마다 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값은 건드리지 않는다).</summary>
private void SetNavCollapsed(bool collapsed)
{
if (viewModel.UiSettings.NavCollapsed == collapsed)
{
return;
}
viewModel.UiSettings.NavCollapsed = collapsed;
viewModel.UiSettings.SaveNavCollapsed();
}
private void NavExpandStrip_Click(object sender, RoutedEventArgs e) => SetNavCollapsed(false);
/// <summary>F11 로 접고 편다. 다른 키 조합(Ctrl · Alt · Shift)은 모듈 몫으로 둔다.</summary>
private void ShellWindow_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.F11 || Keyboard.Modifiers != ModifierKeys.None)
{
return;
}
SetNavCollapsed(!viewModel.UiSettings.NavCollapsed);
e.Handled = true;
}
private void ApplyNavCollapsed(bool collapsed)
{
NavList.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
NavExpandStrip.Visibility = collapsed ? Visibility.Visible : Visibility.Collapsed;
if (!collapsed && viewModel.UiSettings.NavPosition == NavPosition.Bottom)
{
// 접혀 있는 동안에는 폭이 0 이라 밑줄 자리를 잴 수 없다. 편 뒤 배치가 끝나면 다시 맞춘다.
Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)), System.Windows.Threading.DispatcherPriority.Loaded);
}
} }
/// <summary>토스트 오버레이의 정렬/여백을 UiSettings.ToastPosition에 맞춘다. ToastList는 /// <summary>토스트 오버레이의 정렬/여백을 UiSettings.ToastPosition에 맞춘다. ToastList는
@ -417,6 +677,25 @@ public partial class ShellWindow : Window
NavList.Width = isHorizontal ? double.NaN : 140; NavList.Width = isHorizontal ? double.NaN : 140;
NavList.Height = double.NaN; NavList.Height = double.NaN;
// 접었을 때의 띠도 같은 쪽에 붙인다. 아래면 가로 띠(글자 포함), 옆이면 세로 띠(화살표만).
DockPanel.SetDock(NavExpandStrip, DockPanel.GetDock(NavList));
NavExpandStrip.Height = isHorizontal ? 16 : double.NaN;
NavExpandStrip.Width = isHorizontal ? double.NaN : 16;
NavExpandStrip.BorderThickness = position switch
{
NavPosition.Right => new Thickness(1, 0, 0, 0),
NavPosition.Bottom => new Thickness(0, 1, 0, 0),
_ => new Thickness(0, 0, 1, 0)
};
NavExpandLabel.Visibility = isHorizontal ? Visibility.Visible : Visibility.Collapsed;
NavExpandContent.Orientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical;
NavExpandArrowTurn.Angle = position switch
{
NavPosition.Right => -90, // 왼쪽으로 펼친다
NavPosition.Bottom => 0, // 위로 펼친다
_ => 90 // 오른쪽으로 펼친다
};
if (isHorizontal) if (isHorizontal)
{ {
Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false))); Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)));

View File

@ -0,0 +1,199 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace UbisamBase.Core.Shell;
/// <summary>
/// 작업 표시줄 오른쪽 알림 영역(트레이)에 아이콘을 둔다. 창을 최소화하면 작업 표시줄 대신 여기로 내려간다.
///
/// WinForms 의 <c>NotifyIcon</c> 을 쓰지 않는다 - 플랫폼(Core)에 WinForms 의존을 더하면 이 플랫폼으로 만든
/// 모든 프로그램이 함께 지고 간다. 대신 <c>Shell_NotifyIcon</c> 을 직접 부르고, 마우스 알림을 받을
/// <b>메시지 전용 창</b> 하나만 만든다(화면에 보이지 않는다).
///
/// 아이콘은 실행 파일의 것을 그대로 쓴다 - 프로그램마다 자기 아이콘이 나온다. 못 가져오면 윈도 기본 아이콘.
/// </summary>
internal sealed class TrayIcon : IDisposable
{
private const int WM_USER = 0x0400;
private const int TrayCallbackMessage = WM_USER + 1;
private const int WM_LBUTTONUP = 0x0202;
private const int WM_LBUTTONDBLCLK = 0x0203;
private const int WM_RBUTTONUP = 0x0205;
private const int NIM_ADD = 0x0;
private const int NIM_MODIFY = 0x1;
private const int NIM_DELETE = 0x2;
private const int NIF_MESSAGE = 0x1;
private const int NIF_ICON = 0x2;
private const int NIF_TIP = 0x4;
private readonly HwndSource source;
private NOTIFYICONDATA data;
private bool shown;
private bool disposed;
/// <summary>트레이 아이콘을 왼쪽 단추로 눌렀다(창을 다시 보일 때).</summary>
public event EventHandler? Opened;
/// <summary>트레이 아이콘을 오른쪽 단추로 눌렀다(메뉴를 띄울 때).</summary>
public event EventHandler? MenuRequested;
public TrayIcon(string tooltip)
{
// 보이지 않는 메시지 전용 창. 트레이가 보내는 마우스 알림을 받는 자리다.
source = new HwndSource(new HwndSourceParameters("UbisamTrayIcon")
{
Width = 0,
Height = 0,
WindowStyle = 0,
ParentWindow = new IntPtr(-3), // HWND_MESSAGE
});
source.AddHook(OnMessage);
data = new NOTIFYICONDATA
{
cbSize = Marshal.SizeOf<NOTIFYICONDATA>(),
hWnd = source.Handle,
uID = 1,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
uCallbackMessage = TrayCallbackMessage,
hIcon = LoadAppIcon(),
szTip = Trim(tooltip),
};
}
/// <summary>트레이에 아이콘을 올린다. 이미 있으면 글만 고친다.</summary>
public void Show()
{
if (disposed)
{
return;
}
Shell_NotifyIcon(shown ? NIM_MODIFY : NIM_ADD, ref data);
shown = true;
}
/// <summary>트레이에서 아이콘을 내린다.</summary>
public void Hide()
{
if (!shown || disposed)
{
return;
}
Shell_NotifyIcon(NIM_DELETE, ref data);
shown = false;
}
public void Dispose()
{
if (disposed)
{
return;
}
Hide();
disposed = true;
if (data.hIcon != IntPtr.Zero)
{
DestroyIcon(data.hIcon);
data.hIcon = IntPtr.Zero;
}
source.RemoveHook(OnMessage);
source.Dispose();
}
private IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg != TrayCallbackMessage)
{
return IntPtr.Zero;
}
switch (lParam.ToInt32())
{
case WM_LBUTTONUP:
case WM_LBUTTONDBLCLK:
Opened?.Invoke(this, EventArgs.Empty);
handled = true;
break;
case WM_RBUTTONUP:
MenuRequested?.Invoke(this, EventArgs.Empty);
handled = true;
break;
}
return IntPtr.Zero;
}
/// <summary>실행 파일의 아이콘. 못 가져오면 윈도 기본 아이콘(IntPtr.Zero 면 트레이가 빈칸이 되므로 반드시 하나는 준다).</summary>
private static IntPtr LoadAppIcon()
{
try
{
var exe = Process.GetCurrentProcess().MainModule?.FileName;
if (exe is { Length: > 0 })
{
var handle = ExtractIcon(IntPtr.Zero, exe, 0);
if (handle != IntPtr.Zero && handle != new IntPtr(1))
{
return handle;
}
}
}
catch (Exception ex) when (ex is InvalidOperationException || ex is System.ComponentModel.Win32Exception)
{
}
return LoadIcon(IntPtr.Zero, new IntPtr(32512)); // IDI_APPLICATION
}
/// <summary>트레이 글은 128자까지다.</summary>
private static string Trim(string text) =>
string.IsNullOrEmpty(text) ? string.Empty : (text.Length <= 127 ? text : text.Substring(0, 127));
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NOTIFYICONDATA
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int message, ref NOTIFYICONDATA data);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr ExtractIcon(IntPtr instance, string exeFileName, int iconIndex);
[DllImport("user32.dll")]
private static extern IntPtr LoadIcon(IntPtr instance, IntPtr iconName);
[DllImport("user32.dll")]
private static extern bool DestroyIcon(IntPtr icon);
}

View File

@ -1,4 +1,4 @@
<!-- Ubisam Base 아이콘 세트 — 24×24 그리드, 스트로크 렌더링(StrokeThickness 1.5). <!-- Ubisam Base 아이콘 세트 — 24×24 그리드, 스트로크 렌더링(StrokeThickness 1.5).
Path의 Fill은 비우고 Stroke만 쓴다. 색은 상위 Foreground를 상속시키려면 Path의 Fill은 비우고 Stroke만 쓴다. 색은 상위 Foreground를 상속시키려면
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" 처럼 묶으면 된다. Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" 처럼 묶으면 된다.
아이콘을 추가할 때는 Lucide(https://lucide.dev)에서 같은 24px 그리드/스트로크 1.5 규칙으로 가져와 아이콘을 추가할 때는 Lucide(https://lucide.dev)에서 같은 24px 그리드/스트로크 1.5 규칙으로 가져와
@ -51,6 +51,8 @@
<Geometry x:Key="Ubisam.Icon.Fullscreen">M4,9 L4,4 L9,4 M15,4 L20,4 L20,9 M20,15 L20,20 L15,20 M9,20 L4,20 L4,15</Geometry> <Geometry x:Key="Ubisam.Icon.Fullscreen">M4,9 L4,4 L9,4 M15,4 L20,4 L20,9 M20,15 L20,20 L15,20 M9,20 L4,20 L4,15</Geometry>
<Geometry x:Key="Ubisam.Icon.FullscreenExit">M9,4 L9,9 L4,9 M15,4 L15,9 L20,9 M20,15 L15,15 L15,20 M4,15 L9,15 L9,20</Geometry> <Geometry x:Key="Ubisam.Icon.FullscreenExit">M9,4 L9,9 L4,9 M15,4 L15,9 L20,9 M20,15 L15,15 L15,20 M4,15 L9,15 L9,20</Geometry>
<Geometry x:Key="Ubisam.Icon.CollapseNav">M6,8 L12,14 L18,8 M4,19.5 L20,19.5</Geometry>
<!-- 대화상자 아이콘 3종 --> <!-- 대화상자 아이콘 3종 -->
<Geometry x:Key="Ubisam.Icon.Info">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M12,11 L12,16 M12,7.4 L12,7.5</Geometry> <Geometry x:Key="Ubisam.Icon.Info">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M12,11 L12,16 M12,7.4 L12,7.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Question">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M9.3,9.3 A2.7,2.7 0 1 1 13.3,12.6 C12.5,13.1 12,13.6 12,14.8 M12,17.4 L12,17.5</Geometry> <Geometry x:Key="Ubisam.Icon.Question">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M9.3,9.3 A2.7,2.7 0 1 1 13.3,12.6 C12.5,13.1 12,13.6 12,14.8 M12,17.4 L12,17.5</Geometry>

View File

@ -1,4 +1,4 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:UbisamBase.Core.Converters" xmlns:conv="clr-namespace:UbisamBase.Core.Converters"
xmlns:sys="clr-namespace:System;assembly=mscorlib"> xmlns:sys="clr-namespace:System;assembly=mscorlib">
@ -98,12 +98,16 @@
</Setter> </Setter>
</Style> </Style>
<!-- 하단 네비게이션 아이템: 가운데 모임, 아이콘 위 + 라벨 아래. 선택 시 바닥 3px 밑줄(SelectionIndicator)만. --> <!-- 하단 네비게이션 아이템: 가운데 모임, 아이콘 위 + 라벨 아래. 선택 시 바닥 3px 밑줄(SelectionIndicator)만.
높이를 한 단계 줄였다(MergeHub 12차 C-2, 2026-09-28). 위아래 여백 13/14 → 9/10,
아이콘 22 → 20, 아이콘 아래 틈 7 → 5, 글자 13.5 → 12.5 로 74 → 61 DIP(배율 1.2 에서 89 → 73px).
줄인 만큼이 그대로 콘텐츠 높이가 된다 - 코드 칸이 한 줄 더 들어간다. -->
<Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem"> <Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem">
<Setter Property="Padding" Value="30,13,30,14"/> <Setter Property="Padding" Value="30,9,30,10"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/> <Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/> <Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/> <Setter Property="FontSize" Value="12.5"/>
<Setter Property="FontWeight" Value="Medium"/> <Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Cursor" Value="Hand"/> <Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template"> <Setter Property="Template">
@ -116,8 +120,8 @@
Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}" Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
StrokeThickness="1.5" StrokeThickness="1.5"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Width="22" Height="22" Stretch="Uniform" Width="20" Height="20" Stretch="Uniform"
Margin="0,0,0,7" HorizontalAlignment="Center"/> Margin="0,0,0,5" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/> <TextBlock Text="{Binding Title}" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel> </StackPanel>
</Border> </Border>

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
@ -23,7 +23,40 @@ public partial class App : Application
return; return;
} }
UbisamAppBuilder.Run(AppDomain.CurrentDomain.BaseDirectory); try
{
UbisamAppBuilder.Run(AppDomain.CurrentDomain.BaseDirectory);
}
catch (Exception ex)
{
// 켜는 중 실패는 "계속 쓸 수 있습니다" 가 아니다 - 본 창이 없으므로 까닭을 보이고 끝낸다.
// 예전에는 공용 예외 창만 뜨고 창 없는 프로세스가 남았다(설정 폴더를 못 만들 때 실제로 그랬다).
try
{
MessageBox.Show(StartupMessages.StartFailed(ex.Message, LogDirectoryOrNull()),
"켜지 못했습니다", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch (Exception)
{
// 창조차 못 띄우는 상태 - 그래도 프로세스는 남기지 않는다.
}
Shutdown(1);
}
}
/// <summary>로그가 준비됐을 때만 자리를 댄다 - 못 남긴 것을 남았다고 하지 않는다.</summary>
private static string? LogDirectoryOrNull()
{
try
{
var dir = System.IO.Path.Combine(UbisamAppBuilder.ConfigRoot, "Log");
return System.IO.Directory.Exists(dir) ? dir : null;
}
catch (Exception)
{
return null;
}
} }
/// <summary> /// <summary>