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.
243 lines
10 KiB
C#
243 lines
10 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
/// <summary>
|
|
/// The zoom box (creator-corrected model, 2026-10-08): right-clicking the live
|
|
/// preview anchors a CAPTURE window — the box/2 region centred on the click — and
|
|
/// parks a movable 2x magnifier box on screen. The box shows a live, always-on,
|
|
/// magnified view of that anchored capture region (the CLICKED area, never re-follows
|
|
/// the box); dragging it only repositions the box. The content is composited LAST
|
|
/// (layer n+1) by <see cref="Services.Compositor.SceneCompositor"/> from the already-
|
|
/// composed frame, so it captures every layer — prove it with the ZoomMagnifierTests
|
|
/// banded-buffer "box over blue shows green" proof. The transient
|
|
/// <see cref="ZoomSceneElement"/> carries the box placement + capture anchor; the
|
|
/// output crop stays the normal tier (the box is NEVER the crop source).
|
|
/// </summary>
|
|
public partial class MainViewModel
|
|
{
|
|
public void ActivateZoom(double clickX, double clickY)
|
|
{
|
|
var maxW = MasterFrameWidth * 0.7;
|
|
var maxH = MasterFrameHeight * 0.7;
|
|
double w, h;
|
|
switch (_zoomShape)
|
|
{
|
|
case ZoomShape.Circle:
|
|
var size = Math.Min(maxW, maxH);
|
|
w = size; h = size;
|
|
break;
|
|
case ZoomShape.VerticalRectangle:
|
|
h = maxH;
|
|
w = h * 9.0 / 16.0;
|
|
break;
|
|
default:
|
|
// HorizontalRectangle: 4:3 per creator ("more of a 640x480 aspect
|
|
// ratio") — NOT the 16:9 of the first cut, which read as a square.
|
|
h = maxH;
|
|
w = h * 4.0 / 3.0;
|
|
break;
|
|
}
|
|
|
|
// The box AND the capture are both centred on the click; the capture window
|
|
// is the box/2 interior and is FIXED after this call. Each is clamped to the
|
|
// master 1920x1080 frame independently (an edge click can push the capture
|
|
// toward centre while the box still sits right at the edge).
|
|
var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w);
|
|
var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h);
|
|
var capX = Math.Clamp(clickX - w / 4, 0, MasterFrameWidth - w / 2);
|
|
var capY = Math.Clamp(clickY - h / 4, 0, MasterFrameHeight - h / 2);
|
|
_zoomState.X = x;
|
|
_zoomState.Y = y;
|
|
_zoomState.Width = w;
|
|
_zoomState.Height = h;
|
|
_zoomState.CaptureX = capX;
|
|
_zoomState.CaptureY = capY;
|
|
_zoomState.IsActive = true;
|
|
_zoomState.IsMoving = false;
|
|
|
|
var zoomTarget = EnsureZoomLayer(StagedScene);
|
|
zoomTarget.X = x;
|
|
zoomTarget.Y = y;
|
|
zoomTarget.Width = w;
|
|
zoomTarget.Height = h;
|
|
zoomTarget.CaptureX = capX;
|
|
zoomTarget.CaptureY = capY;
|
|
zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional;
|
|
zoomTarget.IsZoomActive = true;
|
|
zoomTarget.IsVisible = true;
|
|
ScheduleSave();
|
|
RefreshZoomUi();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gesture behind the zoom box: activate it centred on the click, or dismiss it if it is
|
|
/// already open. Both halves live here because the old split (activate while live, dismiss
|
|
/// only otherwise) made the gesture do nothing at all when idle — the box could be turned off
|
|
/// but never on (creator, 2026-11-01).
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Settings pill: **Anchor** (default, off) keeps the capture on the clicked point while the
|
|
/// box moves; **Follow** (on) re-anchors the capture to the box centre so the magnifier shows
|
|
/// whatever it hovers over. Persisted, because a creator who chose follow should not have to
|
|
/// choose it again every launch.
|
|
/// </summary>
|
|
private bool _zoomFollowsBox;
|
|
public bool ZoomFollowsBox
|
|
{
|
|
get => _zoomFollowsBox;
|
|
set
|
|
{
|
|
if (!SetProperty(ref _zoomFollowsBox, value)) return;
|
|
_layoutStore.SaveZoomFollowBox(value);
|
|
// Switching to follow mid-hold snaps the capture under the box immediately rather
|
|
// than waiting for the next drag.
|
|
if (value && _zoomState.IsActive)
|
|
{
|
|
ReanchorCaptureToBox();
|
|
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault(e => e.IsZoomActive);
|
|
if (zoomTarget != null)
|
|
{
|
|
zoomTarget.CaptureX = _zoomState.CaptureX;
|
|
zoomTarget.CaptureY = _zoomState.CaptureY;
|
|
}
|
|
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Releasing the box: called when Ctrl goes down while the magnifier is open, so the
|
|
/// creator can dismiss it with the same hand that opened it (in addition to the gesture).</summary>
|
|
public void DismissZoomIfActive() => DismissZoom();
|
|
|
|
public void ToggleZoom(double clickX, double clickY)
|
|
{
|
|
if (_zoomState.IsActive) DismissZoom();
|
|
else ActivateZoom(clickX, clickY);
|
|
}
|
|
|
|
public void DismissZoom()
|
|
{
|
|
if (!_zoomState.IsActive) return;
|
|
_zoomState.IsActive = false;
|
|
_zoomState.IsMoving = false;
|
|
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
|
|
if (zoomTarget != null)
|
|
{
|
|
zoomTarget.IsZoomActive = false;
|
|
zoomTarget.IsVisible = false;
|
|
zoomTarget.CaptureX = 0;
|
|
zoomTarget.CaptureY = 0;
|
|
}
|
|
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
|
RefreshZoomUi();
|
|
}
|
|
|
|
public void StartZoomMove(double x, double y)
|
|
{
|
|
if (!_zoomState.IsActive) return;
|
|
_zoomState.IsMoving = true;
|
|
_zoomState.GrabOffsetX = x - _zoomState.X;
|
|
_zoomState.GrabOffsetY = y - _zoomState.Y;
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
}
|
|
|
|
public void UpdateZoomMove(double x, double y)
|
|
{
|
|
if (!_zoomState.IsActive || !_zoomState.IsMoving) return;
|
|
var nx = x - _zoomState.GrabOffsetX;
|
|
var ny = y - _zoomState.GrabOffsetY;
|
|
_zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width);
|
|
_zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height);
|
|
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault(e => e.IsZoomActive);
|
|
if (zoomTarget != null)
|
|
{
|
|
zoomTarget.X = _zoomState.X;
|
|
zoomTarget.Y = _zoomState.Y;
|
|
// ANCHOR (default): CaptureX/Y are deliberately untouched — dragging relocates the
|
|
// box over the area that was clicked (creator ruling 2026-10-08). FOLLOW (opt-in via
|
|
// the Settings pill): the capture re-anchors to the box centre every move, so the
|
|
// magnifier shows whatever it is hovering over (creator ruling 2026-11-01).
|
|
if (_zoomFollowsBox)
|
|
{
|
|
ReanchorCaptureToBox();
|
|
zoomTarget.CaptureX = _zoomState.CaptureX;
|
|
zoomTarget.CaptureY = _zoomState.CaptureY;
|
|
}
|
|
}
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
RebuildDimRectsForZoom();
|
|
}
|
|
|
|
/// <summary>Point the capture window at the box's current centre. The capture is half the
|
|
/// box's size, exactly as <see cref="ActivateZoom"/> computes it, and clamped to the master
|
|
/// frame independently so an edge drag can keep the box on screen and the capture legal.</summary>
|
|
private void ReanchorCaptureToBox()
|
|
{
|
|
var boxCentreX = _zoomState.X + _zoomState.Width / 2;
|
|
var boxCentreY = _zoomState.Y + _zoomState.Height / 2;
|
|
_zoomState.CaptureX = Math.Clamp(boxCentreX - _zoomState.Width / 4, 0, MasterFrameWidth - _zoomState.Width / 2);
|
|
_zoomState.CaptureY = Math.Clamp(boxCentreY - _zoomState.Height / 4, 0, MasterFrameHeight - _zoomState.Height / 2);
|
|
}
|
|
|
|
public void EndZoomMove()
|
|
{
|
|
if (_zoomState.IsMoving)
|
|
{
|
|
_zoomState.IsMoving = false;
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
}
|
|
}
|
|
|
|
private void RefreshZoomUi()
|
|
{
|
|
OnPropertyChanged(nameof(ZoomState));
|
|
OnPropertyChanged(nameof(IsZoomActive));
|
|
OnPropertyChanged(nameof(ShowZoomOverlay));
|
|
RebuildDimRectsForZoom();
|
|
}
|
|
|
|
private void RebuildDimRectsForZoom()
|
|
{
|
|
if (_zoomState.IsActive)
|
|
{
|
|
var zx = _zoomState.X;
|
|
var zy = _zoomState.Y;
|
|
var zw = _zoomState.Width;
|
|
var zh = _zoomState.Height;
|
|
DimRects.Clear();
|
|
if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight));
|
|
var rx = zx + zw;
|
|
if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight));
|
|
if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy));
|
|
var ry = zy + zh;
|
|
if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry));
|
|
return;
|
|
}
|
|
RebuildQualityDimRects();
|
|
}
|
|
|
|
/// <summary>Heals a missing zoom placeholder on every scene after a layout
|
|
/// load: the invisible 0x0 row mirrored by the healed backgrounds. The zoom box
|
|
/// itself is transient — the placeholder survives so the layer has a seat. The
|
|
/// compositor ranks the ACTIVE box last regardless of this index.</summary>
|
|
private void HealZoomLayers()
|
|
{
|
|
foreach (var scene in Scenes)
|
|
EnsureZoomLayer(scene);
|
|
}
|
|
|
|
/// <summary>Gets or inserts the scene's zoom placeholder at z-index 1.</summary>
|
|
private ZoomSceneElement EnsureZoomLayer(Scene? scene)
|
|
{
|
|
var existing = scene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
|
|
if (existing != null) return existing;
|
|
var zoom = new ZoomSceneElement();
|
|
_sceneGraph.InsertElement(scene!, Math.Min(1, scene!.Elements.Count), zoom);
|
|
return zoom;
|
|
}
|
|
} |