Two creator directives, 2026-11-01.
**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.
**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.
Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).
Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).
Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
217 lines
8.5 KiB
C#
217 lines
8.5 KiB
C#
using System;
|
|
using System.ComponentModel;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using ytLive.Helpers;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive;
|
|
|
|
public partial class MainWindow : Window
|
|
{
|
|
private readonly MainViewModel _viewModel;
|
|
private Controls.TopBar _topBar = null!;
|
|
private Controls.PreviewPane _previewPane = null!;
|
|
private Controls.LeftPanel _leftPanel = null!;
|
|
|
|
public MainWindow()
|
|
{
|
|
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
|
InitializeComponent();
|
|
SizeChanged += (_, e) => Helpers.AppLog.Write($"WIDTHS window={e.NewSize.Width:F0} "
|
|
+ $"layers={LeftPanel.ActualWidth:F0} strip={SceneStrip.ActualWidth:F0} preview={PreviewPane.ActualWidth:F0}");
|
|
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
|
_viewModel = (MainViewModel)DataContext;
|
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
|
// Don't steal focus from an exclusive-fullscreen game the creator is already
|
|
// playing (e.g. while starting a gameplay stream): activating our window would
|
|
// drop that game to the taskbar and make the desktop look "locked".
|
|
ShowActivated = !_viewModel.FullscreenGameRunningNow;
|
|
_topBar = (Controls.TopBar)FindName("TopBar")!;
|
|
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
|
|
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
|
|
UpdateTaskbarOverlay();
|
|
UpdateSelectionOverlay();
|
|
_topBar.RefreshAvatar();
|
|
RestoreWindowGeometry();
|
|
AppLog.Write("MainWindow ctor: end");
|
|
}
|
|
|
|
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
|
{
|
|
AppLog.Write("MainWindow loaded");
|
|
// Web sources need the window HWND as the composition controller's parent,
|
|
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
|
|
// SourceInitialized. Nothing renders from web frames before this.
|
|
_viewModel.InitWebView2();
|
|
}
|
|
|
|
// ─── Window geometry (launch position/size) ───
|
|
// Launch POSITION is derived, never restored: upper-left of the work area plus
|
|
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
|
|
// to [work.Left - width + 120, work.Right - 120] horizontally and
|
|
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
|
|
// almost entirely off-screen, which is what the creator was seeing ("below the
|
|
// fold"). Size and maximized state are still persisted. Placement math is pure and
|
|
// tested (WindowPlacementTests); changing it back to restore position is reverting
|
|
// this block only, since the saved values are still written.
|
|
|
|
/// <summary>Launch inset from the work-area corner, in device-independent units —
|
|
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
|
|
internal const double LaunchPadding = 75;
|
|
|
|
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
|
|
/// window is never pushed off-screen. The clamp is the point: a window that fits
|
|
/// stays wholly visible, and one too big to fit falls back to the work-area origin
|
|
/// rather than hanging off an edge.</summary>
|
|
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
|
|
{
|
|
var left = workArea.Left + padding;
|
|
var top = workArea.Top + padding;
|
|
|
|
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
|
|
else left = workArea.Left;
|
|
|
|
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
|
|
else top = workArea.Top;
|
|
|
|
return new Point(left, top);
|
|
}
|
|
|
|
private void RestoreWindowGeometry()
|
|
{
|
|
try
|
|
{
|
|
var work = SystemParameters.WorkArea;
|
|
var saved = _viewModel.RestoreWindowState();
|
|
|
|
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
|
|
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
|
|
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
|
|
Width = width;
|
|
Height = height;
|
|
|
|
var place = TopLeftPlacement(work, width, height, LaunchPadding);
|
|
Left = place.X;
|
|
Top = place.Y;
|
|
|
|
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Window geometry restore failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void SaveWindowGeometry()
|
|
{
|
|
try
|
|
{
|
|
var bounds = RestoreBounds;
|
|
var state = WindowState == WindowState.Maximized ? "Maximized" : "Normal";
|
|
_viewModel.PersistWindowState(bounds.Left, bounds.Top, bounds.Width, bounds.Height, state);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"Window geometry save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private ytLive.Services.GlobalHotkeyManager? _hotkeys;
|
|
|
|
protected override void OnSourceInitialized(EventArgs e)
|
|
{
|
|
base.OnSourceInitialized(e);
|
|
_hotkeys = new ytLive.Services.GlobalHotkeyManager();
|
|
_hotkeys.Activated += id => _viewModel.HandleHotkey(id);
|
|
_hotkeys.Attach(this, _viewModel.CurrentHotkeyBindings);
|
|
_viewModel.HotkeyBindingsChanged = bindings => _hotkeys.Reattach(this, bindings);
|
|
}
|
|
|
|
private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e)
|
|
{
|
|
if (!_viewModel.IsEditMode)
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(MainViewModel.IsLive))
|
|
UpdateTaskbarOverlay();
|
|
else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
|
|
UpdateSelectionOverlay();
|
|
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|
|
|| e.PropertyName == nameof(MainViewModel.IsConnected))
|
|
_topBar.RefreshAvatar();
|
|
}
|
|
|
|
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
|
{
|
|
SaveWindowGeometry();
|
|
_viewModel.Shutdown();
|
|
}
|
|
|
|
private void MainWindow_Activated(object? sender, EventArgs e)
|
|
{
|
|
_viewModel.RefreshBackgroundAutoCapture();
|
|
}
|
|
|
|
private void MainWindow_Deactivated(object? sender, EventArgs e)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ctrl released/late-pressed while the magnifier is open → the box closes (creator ruling
|
|
/// 2026-11-01). This is the "let go and it's gone" half of the zoom gesture; it also means one
|
|
/// hand can dismiss without reaching for the mouse. Creating the box still takes Ctrl+right
|
|
/// (edit) or a left click (preview), so holding Ctrl to *make* it never cancels it: the key is
|
|
/// already down before the box exists.
|
|
/// </summary>
|
|
private void MainWindow_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
|
{
|
|
if (e.Key is System.Windows.Input.Key.LeftCtrl or System.Windows.Input.Key.RightCtrl
|
|
&& _viewModel.IsZoomActive)
|
|
{
|
|
_viewModel.DismissZoomIfActive();
|
|
}
|
|
}
|
|
|
|
private void UpdateTaskbarOverlay()
|
|
{
|
|
TaskbarInfo.Overlay = _viewModel.IsLive
|
|
? (ImageSource)FindResource("LiveOverlay")
|
|
: null;
|
|
}
|
|
|
|
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (e.OriginalSource is not DependencyObject original) return;
|
|
var form = _viewModel.BroadcastForm;
|
|
var ypp = _viewModel.Ypp;
|
|
var test = _viewModel.TestSession;
|
|
if (!_previewPane.IsClickInsideDrawer(original))
|
|
{
|
|
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
|
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
|
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
|
}
|
|
if (_previewPane.IsClickInsidePreview(original)) return;
|
|
if (_leftPanel.IsClickInside(original)) return;
|
|
_viewModel.SelectedElement = null;
|
|
}
|
|
|
|
private void UpdateSelectionOverlay()
|
|
{
|
|
if (!_viewModel.IsEditMode)
|
|
{
|
|
_previewPane.UpdateSelectionOverlay(false);
|
|
return;
|
|
}
|
|
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
|
|
_previewPane.UpdateSelectionOverlay(selected);
|
|
_leftPanel.OnSelectionChanged(_viewModel);
|
|
}
|
|
}
|