ksj09406 9af4bf3bdb 플랫폼 묶음 - 트레이 · 창 자리 기억 · 세 갈래 묻는 창 · 탭 줄 크기
새 플랫폼(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>
2026-09-28 13:21:21 +09:00

744 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 });
}
}