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 @@ - + + + 창모드일 때의 마지막 자리(Win32 좌표). 닫을 때 이 값을 기억한다. + private Rect lastWindowedRect; + + /// 트레이 아이콘. 최소화해서 트레이로 내려갈 때 처음 만든다. + private TrayIcon? tray; + + private ContextMenu? trayMenu; + + /// 트레이로 내려가기 바로 전의 창 모양. 되살릴 때 그대로 돌려놓는다. + private WindowState stateBeforeTray = WindowState.Normal; + public ShellWindow(ShellViewModel viewModel) { InitializeComponent(); @@ -44,6 +55,8 @@ public partial class ShellWindow : Window Closed += (_, _) => PlatformChrome.FullBleedChanged -= OnFullBleedChanged; ApplyNavPosition(viewModel.UiSettings.NavPosition); ApplyToastPosition(viewModel.UiSettings.ToastPosition); + Loaded += (_, _) => ApplyNavCollapsed(viewModel.UiSettings.NavCollapsed); + PreviewKeyDown += ShellWindow_PreviewKeyDown; // 하단 탭 바 = 등록된 실제 탭(MainTabs, 왼쪽에 모임) + "화면 잠금"/"바탕화면"/"종료" 같은 // 동작형 항목(오른쪽 끝에 붙음, ShellStyles.xaml의 DockPanel.Dock=Right 트리거로 처리). @@ -56,7 +69,9 @@ public partial class ShellWindow : Window // Shutdown()이 아니라 Close()다 — 그래야 ICloseGuard(저장 안 한 변경 확인)를 거친다. new NavActionItem("종료", UbisamBase.Core.Modules.Icon.Power, Close), 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; @@ -101,6 +116,8 @@ public partial class ShellWindow : Window /// 셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다. protected override void OnClosed(EventArgs e) { + SavePlacement(); + tray?.Dispose(); base.OnClosed(e); Application.Current?.Shutdown(); } @@ -177,7 +194,177 @@ public partial class ShellWindow : Window protected override void OnSourceInitialized(EventArgs 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(); + } + + /// 창모드로 떠 있는 동안의 자리를 들고 있는다. 닫을 때 이 값을 기억한다. + 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; + } + + /// + /// 최소화한 창이 잠깐 놓이는 자리(-32000, -32000)인지. + /// + /// 최소화하는 동안 창이 먼저 저 자리로 옮겨지고 그 다음에 모양이 "최소화"로 바뀐다. + /// 그 틈에 자리를 기억해 버리면 되살릴 때 화면 밖이라 어느 모니터에도 걸치지 않아, + /// 창이 주 모니터 가운데 최소 크기로 쪼그라들었다. + /// + private static bool IsParked(Rect rect) => rect.Left <= -30000 || rect.Top <= -30000; + + /// 마지막 모드와 창모드 자리를 파일에 남긴다. 못 써도 그냥 넘어간다. + 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)); + } + + /// + /// 최대화한 창이 그 모니터보다 크면 모니터 크기로 다시 놓는다. + /// + /// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 잘렸다. + /// 이미 맞으면 아무것도 하지 않아 다시 놓기가 되풀이되지 않는다. + /// + 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); + } + + /// + /// 최소화하면 작업 표시줄 대신 트레이(알림 영역)로 내려간다. + /// + /// 창을 로 감추므로 작업 표시줄 단추도 함께 사라진다 - 되살리는 길은 트레이 아이콘뿐이라 + /// 아이콘을 먼저 올리고 나서 감춘다. [바탕화면] 단추로 내려도 같다. + /// + private void OnStateChanged() + { + if (WindowState != WindowState.Minimized) + { + // 최소화가 아닌 마지막 모양을 들고 있는다 - 트레이에서 되살릴 때 이 모양으로 돌아간다. + stateBeforeTray = WindowState; + return; + } + + if (!IsVisible) + { + return; + } + + EnsureTray().Show(); + Hide(); + } + + /// 트레이에서 창을 되살린다. 내려가기 전 모양(전체화면 · 최대화 · 창모드)으로 돌아간다. + 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; + } + + /// 트레이 아이콘 오른쪽 단추 메뉴 - [열기] · [종료]. + 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; } /// 작업표시줄까지 덮는 전체화면. Topmost는 걸지 않는다 — 현장의 다른 경보/알림 창이 @@ -228,19 +415,50 @@ public partial class ShellWindow : Window /// 이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와 /// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을 /// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로. - private void ApplyWindowed() + private void ApplyWindowed() => ApplyWindowed(lastWindowedRect); + + /// + /// 이동 · 크기 조절이 되는 창모드. 기억한 자리가 있으면 그 자리로 - 없으면 지금까지처럼 1200×760 가운데. + /// + /// 기억한 자리는 모니터 안으로 되돌려 놓는다(). + /// 모니터를 떼거나 해상도 · 배율이 바뀌면 그 자리가 화면 밖일 수 있어, 그대로 놓으면 창을 잡을 수 없다. + /// + /// + /// 기억한 자리를 모니터 안으로 밀지. 켤 때는 참이다 - 지난번과 모니터 · 배율이 다를 수 있다. + /// 트레이에서 되살릴 때는 거짓 - 몇 초 전 그 자리이므로 그대로 놓아야 크기가 달라지지 않는다. + /// + private void ApplyWindowed(Rect remembered, bool fit = true) { isKiosk = false; ResizeMode = ResizeMode.CanResizeWithGrip; WindowState = WindowState.Normal; - 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); + if (remembered.Width > 0 && remembered.Height > 0) + { + 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; - + RememberWindowedRect(); SetFullscreenButtonState(toWindowed: false); } @@ -330,6 +548,48 @@ public partial class ShellWindow : Window { ApplyToastPosition(((UiSettingsService)sender!).ToastPosition); } + else if (e.PropertyName == nameof(UiSettingsService.NavCollapsed)) + { + ApplyNavCollapsed(((UiSettingsService)sender!).NavCollapsed); + } + } + + /// 탭 줄을 접거나 편다. 누를 때마다 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값은 건드리지 않는다). + 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); + + /// F11 로 접고 편다. 다른 키 조합(Ctrl · Alt · Shift)은 모듈 몫으로 둔다. + 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); + } } /// 토스트 오버레이의 정렬/여백을 UiSettings.ToastPosition에 맞춘다. ToastList는 @@ -417,6 +677,25 @@ public partial class ShellWindow : Window NavList.Width = isHorizontal ? double.NaN : 140; 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) { Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false))); diff --git a/src/UbisamBase.Core/Shell/TrayIcon.cs b/src/UbisamBase.Core/Shell/TrayIcon.cs new file mode 100644 index 0000000..842e49d --- /dev/null +++ b/src/UbisamBase.Core/Shell/TrayIcon.cs @@ -0,0 +1,199 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; + +namespace UbisamBase.Core.Shell; + +/// +/// 작업 표시줄 오른쪽 알림 영역(트레이)에 아이콘을 둔다. 창을 최소화하면 작업 표시줄 대신 여기로 내려간다. +/// +/// WinForms 의 NotifyIcon 을 쓰지 않는다 - 플랫폼(Core)에 WinForms 의존을 더하면 이 플랫폼으로 만든 +/// 모든 프로그램이 함께 지고 간다. 대신 Shell_NotifyIcon 을 직접 부르고, 마우스 알림을 받을 +/// 메시지 전용 창 하나만 만든다(화면에 보이지 않는다). +/// +/// 아이콘은 실행 파일의 것을 그대로 쓴다 - 프로그램마다 자기 아이콘이 나온다. 못 가져오면 윈도 기본 아이콘. +/// +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; + + /// 트레이 아이콘을 왼쪽 단추로 눌렀다(창을 다시 보일 때). + public event EventHandler? Opened; + + /// 트레이 아이콘을 오른쪽 단추로 눌렀다(메뉴를 띄울 때). + 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(), + hWnd = source.Handle, + uID = 1, + uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP, + uCallbackMessage = TrayCallbackMessage, + hIcon = LoadAppIcon(), + szTip = Trim(tooltip), + }; + } + + /// 트레이에 아이콘을 올린다. 이미 있으면 글만 고친다. + public void Show() + { + if (disposed) + { + return; + } + + Shell_NotifyIcon(shown ? NIM_MODIFY : NIM_ADD, ref data); + shown = true; + } + + /// 트레이에서 아이콘을 내린다. + 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; + } + + /// 실행 파일의 아이콘. 못 가져오면 윈도 기본 아이콘(IntPtr.Zero 면 트레이가 빈칸이 되므로 반드시 하나는 준다). + 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 + } + + /// 트레이 글은 128자까지다. + 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); +} diff --git a/src/UbisamBase.Core/Themes/Icons.xaml b/src/UbisamBase.Core/Themes/Icons.xaml index 838c385..464c44e 100644 --- a/src/UbisamBase.Core/Themes/Icons.xaml +++ b/src/UbisamBase.Core/Themes/Icons.xaml @@ -1,4 +1,4 @@ - 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 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 diff --git a/src/UbisamBase.Core/Themes/ShellStyles.xaml b/src/UbisamBase.Core/Themes/ShellStyles.xaml index fc70681..0d04dec 100644 --- a/src/UbisamBase.Core/Themes/ShellStyles.xaml +++ b/src/UbisamBase.Core/Themes/ShellStyles.xaml @@ -1,4 +1,4 @@ - @@ -98,12 +98,16 @@ - +