feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
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.
This commit is contained in:
@@ -79,6 +79,41 @@ public partial class MainViewModel
|
||||
/// 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();
|
||||
@@ -123,13 +158,32 @@ public partial class MainViewModel
|
||||
{
|
||||
zoomTarget.X = _zoomState.X;
|
||||
zoomTarget.Y = _zoomState.Y;
|
||||
// CaptureX/Y are deliberately NOT touched: dragging relocates the box,
|
||||
// never the anchored captured area (creator ruling 2026-10-08).
|
||||
// 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)
|
||||
|
||||
@@ -277,6 +277,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
// (yellow) instead of silently falling back to the default.
|
||||
MicSourceName = _layoutStore.LoadMicSourceName();
|
||||
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
|
||||
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
|
||||
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
|
||||
_musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default
|
||||
InitScoreboard();
|
||||
|
||||
Reference in New Issue
Block a user