새 플랫폼(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>
744 lines
30 KiB
C#
744 lines
30 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.ComponentModel;
|
||
using System.Threading.Tasks;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Data;
|
||
using System.Windows.Input;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Animation;
|
||
using UbisamBase.Core.Capture;
|
||
using UbisamBase.Core.Locking;
|
||
using UbisamBase.Core.Logging;
|
||
using UbisamBase.Core.Messaging;
|
||
using UbisamBase.Core.Modules;
|
||
using UbisamBase.Core.Settings;
|
||
|
||
namespace UbisamBase.Core.Shell;
|
||
|
||
public partial class ShellWindow : Window
|
||
{
|
||
/// <summary>전역 탈출 단축키(Ctrl+Shift+F, FullscreenHotkeyInstaller)가 어느 창에서 눌리든
|
||
/// 이 인스턴스에 닿을 수 있도록 하는 참조. 앱에 Shell은 하나뿐이다.</summary>
|
||
internal static ShellWindow? Instance { get; private set; }
|
||
|
||
private readonly ShellViewModel viewModel;
|
||
private MainTabViewModel? lastRealTab;
|
||
private bool suppressSelectionSync;
|
||
private bool isKiosk;
|
||
private bool closeApproved;
|
||
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)
|
||
{
|
||
InitializeComponent();
|
||
Instance = this;
|
||
this.viewModel = viewModel;
|
||
DataContext = viewModel;
|
||
|
||
viewModel.UiSettings.PropertyChanged += OnUiSettingsChanged;
|
||
|
||
// FullBleed는 첨부 속성이라 바인딩이 스스로 변화를 못 본다 — 바뀌면 여백을 다시 계산한다.
|
||
PlatformChrome.FullBleedChanged += OnFullBleedChanged;
|
||
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 트리거로 처리).
|
||
// DockPanel은 자식을 등록 순서대로 오른쪽부터 채우므로, 화면에 보일 순서의 정반대 순서로
|
||
// 넣어야 한다 — 맨 먼저 넣는 항목이 실제 오른쪽 가장자리를 차지하기 때문이다.
|
||
// 스크린샷/로그/창모드로는 하단이 아니라 상단 브랜딩 바 버튼(ScreenshotButton 등, XAML)으로 있다.
|
||
var composite = new CompositeCollection
|
||
{
|
||
new CollectionContainer { Collection = viewModel.MainTabs },
|
||
// 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()),
|
||
// 탭 줄 접기 - 콘텐츠를 세로(또는 가로)로 더 보이게. 접으면 얇은 띠가 남고, 띠나 F11 로 다시 편다.
|
||
new NavActionItem("탭 접기", UbisamBase.Core.Modules.Icon.CollapseNav, () => SetNavCollapsed(true))
|
||
};
|
||
NavList.ItemsSource = composite;
|
||
|
||
lastRealTab = viewModel.SelectedMainTab;
|
||
NavList.SelectedItem = viewModel.SelectedMainTab;
|
||
NavList.SelectionChanged += NavList_SelectionChanged;
|
||
|
||
NavList.SelectionChanged += (_, _) => MoveTabIndicator(animate: true);
|
||
NavList.Loaded += (_, _) => MoveTabIndicator(animate: false);
|
||
NavList.SizeChanged += (_, _) => MoveTabIndicator(animate: false);
|
||
}
|
||
|
||
/// <summary>닫기 전에 열려 있는 모듈 화면에 "지금 닫아도 되는지" 물어본다(ICloseGuard).
|
||
/// 묻는 일이 비동기라 일단 닫기를 취소해 두고, 전부 동의하면 그때 다시 Close()를 부른다.
|
||
/// 모듈이 셸 창의 Closing에 직접 붙어 각자 Cancel/Close를 거는 충돌을 막기 위한 단일 통로다.</summary>
|
||
protected override void OnClosing(CancelEventArgs e)
|
||
{
|
||
base.OnClosing(e);
|
||
|
||
if (e.Cancel || closeApproved)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (closeAsking)
|
||
{
|
||
// 이미 묻고 있는 중이면 그 답을 기다린다 — 중복으로 묻지 않는다.
|
||
e.Cancel = true;
|
||
return;
|
||
}
|
||
|
||
var guards = CollectCloseGuards();
|
||
if (guards.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
e.Cancel = true;
|
||
_ = AskCloseGuardsAsync(guards);
|
||
}
|
||
|
||
/// <summary>셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다.</summary>
|
||
protected override void OnClosed(EventArgs e)
|
||
{
|
||
SavePlacement();
|
||
tray?.Dispose();
|
||
base.OnClosed(e);
|
||
Application.Current?.Shutdown();
|
||
}
|
||
|
||
private async Task AskCloseGuardsAsync(List<ICloseGuard> guards)
|
||
{
|
||
closeAsking = true;
|
||
try
|
||
{
|
||
foreach (var guard in guards)
|
||
{
|
||
if (!await guard.CanCloseAsync())
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
closeApproved = true;
|
||
Close();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// 확인 과정이 깨졌다고 창을 못 닫게 두면 사용자가 앱을 끌 방법이 없어진다.
|
||
new Logger("Shell").E("닫기 확인 중 오류 — 그대로 닫는다", ex);
|
||
closeApproved = true;
|
||
Close();
|
||
}
|
||
finally
|
||
{
|
||
closeAsking = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>이미 열린 적 있는 탭 화면(과 그 DataContext) 중 ICloseGuard를 구현한 것만 모은다.
|
||
/// 아직 한 번도 안 연 탭은 만들지 않는다 — 물어보려고 화면을 새로 띄우는 꼴이 되기 때문.</summary>
|
||
private List<ICloseGuard> CollectCloseGuards()
|
||
{
|
||
var guards = new List<ICloseGuard>();
|
||
|
||
foreach (var tab in viewModel.MainTabs)
|
||
{
|
||
AddCloseGuard(guards, tab.CreatedContent);
|
||
|
||
foreach (var sub in tab.SubTabs)
|
||
{
|
||
AddCloseGuard(guards, sub.CreatedContent);
|
||
}
|
||
}
|
||
|
||
return guards;
|
||
}
|
||
|
||
private static void AddCloseGuard(List<ICloseGuard> guards, object? content)
|
||
{
|
||
if (content == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (content is ICloseGuard guard && !guards.Contains(guard))
|
||
{
|
||
guards.Add(guard);
|
||
}
|
||
|
||
if (content is FrameworkElement element && element.DataContext is ICloseGuard viewModelGuard &&
|
||
!guards.Contains(viewModelGuard))
|
||
{
|
||
guards.Add(viewModelGuard);
|
||
}
|
||
}
|
||
|
||
/// <summary>창 핸들이 생겨야 모니터 판별(MonitorHelper)이 가능하므로, 시작 시 kiosk 배치는
|
||
/// 여기서 한다(생성자 시점엔 아직 핸들이 없을 수 있음).</summary>
|
||
protected override void OnSourceInitialized(EventArgs e)
|
||
{
|
||
base.OnSourceInitialized(e);
|
||
|
||
// 지난번에 창모드로 껐으면 그 자리 · 크기로, 아니면 지금까지처럼 전체화면으로 켠다.
|
||
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는 걸지 않는다 — 현장의 다른 경보/알림 창이
|
||
/// 이 화면에 가려지면 안 된다는 요구 때문(사용자 결정). 대신 탈출은 이 버튼 또는
|
||
/// Ctrl+Shift+F(FullscreenHotkeyInstaller)로 항상 가능하다.</summary>
|
||
private void ApplyKiosk()
|
||
{
|
||
isKiosk = true;
|
||
var bounds = MonitorHelper.GetBounds(this);
|
||
ResizeMode = ResizeMode.NoResize;
|
||
WindowState = WindowState.Normal;
|
||
Left = bounds.Left;
|
||
Top = bounds.Top;
|
||
Width = bounds.Width;
|
||
Height = bounds.Height;
|
||
Topmost = false;
|
||
FitToMonitor(bounds);
|
||
|
||
// 버튼 아이콘/툴팁은 "다음에 누르면 어떻게 되는지"를 나타낸다 — 지금 kiosk이므로 다음 동작은 창모드 전환.
|
||
SetFullscreenButtonState(toWindowed: true);
|
||
}
|
||
|
||
/// <summary>모니터 경계를 그대로 Width/Height(DIP)에 넣어도 환경에 따라 창이 몇 px 더 크게
|
||
/// 만들어져(DPI 배율 반올림 등) 오른쪽·아래가 화면 밖으로 밀리고, 그만큼 하단 네비게이션 바가
|
||
/// 잘려 보인다. 그래서 만들어진 창을 실측해 모니터와의 차이만큼 되돌린다.
|
||
/// 실측값과 목표값은 둘 다 Win32 좌표계라, 그 비율로 DIP 환산까지 함께 처리된다.</summary>
|
||
private void FitToMonitor(Rect bounds)
|
||
{
|
||
var actual = MonitorHelper.GetWindowBounds(this);
|
||
if (actual.IsEmpty || actual.Width <= 0 || actual.Height <= 0 || Width <= 0 || Height <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var pixelsPerDipX = actual.Width / Width;
|
||
var pixelsPerDipY = actual.Height / Height;
|
||
if (pixelsPerDipX <= 0 || pixelsPerDipY <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Width = bounds.Width / pixelsPerDipX;
|
||
Height = bounds.Height / pixelsPerDipY;
|
||
Left -= (actual.Left - bounds.Left) / pixelsPerDipX;
|
||
Top -= (actual.Top - bounds.Top) / pixelsPerDipY;
|
||
}
|
||
|
||
/// <summary>이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와
|
||
/// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을
|
||
/// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로.</summary>
|
||
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;
|
||
ResizeMode = ResizeMode.CanResizeWithGrip;
|
||
WindowState = WindowState.Normal;
|
||
|
||
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);
|
||
}
|
||
|
||
/// <summary>버튼 툴팁("모드 변경")은 고정이고, 아이콘만 "다음 클릭이 어느 방향인지"를 나타낸다 —
|
||
/// toWindowed=true(지금 kiosk)면 창모드로 나가는 아이콘을, false(지금 창모드)면 전체화면으로
|
||
/// 들어가는 아이콘을 보여준다.</summary>
|
||
private void SetFullscreenButtonState(bool toWindowed)
|
||
{
|
||
FullscreenIconPath.Data = (Geometry)FindResource(toWindowed ? "Ubisam.Icon.FullscreenExit" : "Ubisam.Icon.Fullscreen");
|
||
}
|
||
|
||
/// <summary>같은 버튼(또는 Ctrl+Shift+F)으로 kiosk ↔ 창모드를 오간다.</summary>
|
||
internal void ToggleFullscreen()
|
||
{
|
||
try
|
||
{
|
||
if (isKiosk)
|
||
{
|
||
ApplyWindowed();
|
||
}
|
||
else
|
||
{
|
||
ApplyKiosk();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// 전환 중 예외가 나도 조작 불가 상태(중간 프레임)로 남지 않도록 창모드로 안전 폴백한다.
|
||
new UbisamBase.Core.Logging.Logger("Shell").E("전체화면 전환 실패", ex);
|
||
ResizeMode = ResizeMode.CanResizeWithGrip;
|
||
WindowState = WindowState.Normal;
|
||
Topmost = false;
|
||
isKiosk = false;
|
||
SetFullscreenButtonState(toWindowed: false);
|
||
}
|
||
}
|
||
|
||
/// <summary>창모드에서만 브랜딩 바를 끌어 창을 옮길 수 있다. kiosk에서는 화면 전체를 채우고 있어
|
||
/// 이동이 의미가 없으므로 무시한다.</summary>
|
||
private void BrandBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (!isKiosk)
|
||
{
|
||
DragMove();
|
||
}
|
||
}
|
||
|
||
private void ScreenshotButton_Click(object sender, RoutedEventArgs e) => ScreenshotService.Capture(this);
|
||
|
||
private void LogButton_Click(object sender, RoutedEventArgs e) => LogViewerWindow.ShowOrFocus(this);
|
||
|
||
private void FullscreenButton_Click(object sender, RoutedEventArgs e) => ToggleFullscreen();
|
||
|
||
private void NavList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||
{
|
||
if (suppressSelectionSync)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (NavList.SelectedItem is NavActionItem action)
|
||
{
|
||
action.Execute();
|
||
|
||
// 동작형 항목은 화면 전환이 아니므로 원래 선택돼 있던 실제 탭으로 되돌린다.
|
||
suppressSelectionSync = true;
|
||
NavList.SelectedItem = lastRealTab;
|
||
suppressSelectionSync = false;
|
||
}
|
||
else if (NavList.SelectedItem is MainTabViewModel tab)
|
||
{
|
||
lastRealTab = tab;
|
||
viewModel.SelectedMainTab = tab;
|
||
}
|
||
}
|
||
|
||
private void OnFullBleedChanged(object? sender, EventArgs e)
|
||
=> BindingOperations.GetMultiBindingExpression(ContentBorder, Border.PaddingProperty)?.UpdateTarget();
|
||
|
||
private void OnUiSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||
{
|
||
if (e.PropertyName == nameof(UiSettingsService.NavPosition))
|
||
{
|
||
ApplyNavPosition(((UiSettingsService)sender!).NavPosition);
|
||
}
|
||
else if (e.PropertyName == nameof(UiSettingsService.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는
|
||
/// 콘텐츠와 같은 Grid Row에 있어서(ShellWindow.xaml) 상단 브랜딩 바·하단 서브탭 줄과는
|
||
/// 애초에 겹치지 않으므로, 여백은 콘텐츠 영역 안쪽 여유만 두면 된다.</summary>
|
||
private void ApplyToastPosition(ToastPosition position)
|
||
{
|
||
var isLeft = position is ToastPosition.TopLeft or ToastPosition.BottomLeft;
|
||
var isTop = position is ToastPosition.TopLeft or ToastPosition.TopRight;
|
||
|
||
ToastList.HorizontalAlignment = isLeft ? HorizontalAlignment.Left : HorizontalAlignment.Right;
|
||
ToastList.VerticalAlignment = isTop ? VerticalAlignment.Top : VerticalAlignment.Bottom;
|
||
ToastList.Margin = new Thickness(20);
|
||
|
||
// 상단 배치는 새 토스트가 목록 앞(=쌓임의 시작점=앵커에 가장 가까운 자리)에 와야 위에 뜨고,
|
||
// 하단 배치는 StackPanel이 항상 위→아래로 쌓기 때문에 뒤에 추가하는 것만으로 이미 앵커(화면 하단)에
|
||
// 가장 가깝게 붙는다 — 그래서 위치가 바뀔 때마다 쌓는 방향도 같이 바꿔줘야 한다.
|
||
ToastService.Current.StackNewestFirst = isTop;
|
||
}
|
||
|
||
/// <summary>토스트가 새로 뜰 때 배치된 쪽(좌/우) 화면 끝에서 미끄러져 들어오는 애니메이션.</summary>
|
||
private void ToastItem_Loaded(object sender, RoutedEventArgs e)
|
||
{
|
||
if (sender is not Border border || border.RenderTransform is not TranslateTransform transform)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var fromLeft = ToastList.HorizontalAlignment == HorizontalAlignment.Left;
|
||
var distance = border.ActualWidth > 0 ? border.ActualWidth + 60 : 420;
|
||
var startX = fromLeft ? -distance : distance;
|
||
|
||
transform.BeginAnimation(TranslateTransform.XProperty,
|
||
new DoubleAnimation(startX, 0, new Duration(TimeSpan.FromMilliseconds(260)))
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||
});
|
||
|
||
// 표시 시간이 끝나면 ToastService가 IsClosing을 켠다 — 그 순간 등장과 같은 방향의 화면 끝으로
|
||
// 다시 미끄러져 나가는 퇴장 애니메이션을 재생한다. 컬렉션에서의 실제 제거는 ToastService가
|
||
// 이 애니메이션 길이만큼 기다렸다가 한다.
|
||
if (border.DataContext is ToastMessage toast)
|
||
{
|
||
PropertyChangedEventHandler? handler = null;
|
||
handler = (_, args) =>
|
||
{
|
||
if (args.PropertyName != nameof(ToastMessage.IsClosing) || !toast.IsClosing)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var exitDistance = border.ActualWidth > 0 ? border.ActualWidth + 60 : 420;
|
||
var endX = fromLeft ? -exitDistance : exitDistance;
|
||
|
||
border.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(1, 0, new Duration(TimeSpan.FromMilliseconds(220))));
|
||
transform.BeginAnimation(TranslateTransform.XProperty,
|
||
new DoubleAnimation(0, endX, new Duration(TimeSpan.FromMilliseconds(220)))
|
||
{
|
||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
||
});
|
||
|
||
toast.PropertyChanged -= handler;
|
||
};
|
||
toast.PropertyChanged += handler;
|
||
|
||
border.Unloaded += (_, _) => toast.PropertyChanged -= handler;
|
||
}
|
||
}
|
||
|
||
private void ApplyNavPosition(NavPosition position)
|
||
{
|
||
DockPanel.SetDock(NavList, position switch
|
||
{
|
||
NavPosition.Right => Dock.Right,
|
||
NavPosition.Bottom => Dock.Bottom,
|
||
_ => Dock.Left
|
||
});
|
||
|
||
var isHorizontal = position == NavPosition.Bottom;
|
||
NavList.Style = (Style)FindResource(isHorizontal
|
||
? "Ubisam.Style.SidebarBar.Horizontal"
|
||
: "Ubisam.Style.SidebarBar.Vertical");
|
||
|
||
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)));
|
||
}
|
||
}
|
||
|
||
/// <summary>하단 탭의 선택 인디케이터를 선택된 탭 아래로 옮긴다. 좌/우 네비게이션일 때는 인디케이터가 없어 무시된다.</summary>
|
||
private void MoveTabIndicator(bool animate)
|
||
{
|
||
if (NavList.Template?.FindName("SelectionIndicator", NavList) is not Border indicator ||
|
||
NavList.Template.FindName("SelectionIndicatorShift", NavList) is not TranslateTransform shift)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (NavList.SelectedItem == null ||
|
||
NavList.ItemContainerGenerator.ContainerFromItem(NavList.SelectedItem) is not ListBoxItem container ||
|
||
!container.IsArrangeValid)
|
||
{
|
||
indicator.Width = 0;
|
||
return;
|
||
}
|
||
|
||
var origin = container.TranslatePoint(new Point(0, 0), NavList);
|
||
var targetX = origin.X;
|
||
var targetWidth = container.ActualWidth;
|
||
|
||
if (!animate)
|
||
{
|
||
shift.BeginAnimation(TranslateTransform.XProperty, null);
|
||
indicator.BeginAnimation(FrameworkElement.WidthProperty, null);
|
||
shift.X = targetX;
|
||
indicator.Width = targetWidth;
|
||
return;
|
||
}
|
||
|
||
var duration = new Duration(TimeSpan.FromMilliseconds(260));
|
||
var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
|
||
|
||
shift.BeginAnimation(TranslateTransform.XProperty,
|
||
new DoubleAnimation(targetX, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd });
|
||
indicator.BeginAnimation(FrameworkElement.WidthProperty,
|
||
new DoubleAnimation(targetWidth, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd });
|
||
}
|
||
}
|