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:
@@ -112,6 +112,17 @@
|
||||
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Zoom box (creator ruling 2026-11-01): Anchor keeps the magnification on
|
||||
the point that was clicked; Follow re-anchors it to the box as it is
|
||||
dragged. Anchor is the default and the persisted norm. -->
|
||||
<TextBlock Text="Zoom box" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,0,0,8">
|
||||
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding ZoomFollowsBox, Mode=TwoWay}" VerticalAlignment="Center"
|
||||
ToolTip="Off: the box magnifies the area you clicked. On: it magnifies whatever it hovers over."/>
|
||||
<TextBlock Text="Follow the box" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Development panel host. Empty in a Release build: the panel itself is
|
||||
excluded from the Release compile, and OverlayHost.xaml.cs only fills
|
||||
this inside #if DEBUG. Never put content here directly. -->
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
Loaded="MainWindow_Loaded"
|
||||
Activated="MainWindow_Activated"
|
||||
Deactivated="MainWindow_Deactivated"
|
||||
PreviewKeyDown="MainWindow_PreviewKeyDown"
|
||||
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"
|
||||
ContextMenuOpening="Window_ContextMenuOpening">
|
||||
|
||||
|
||||
@@ -162,6 +162,22 @@ public partial class MainWindow : Window
|
||||
{
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -45,6 +45,13 @@ public partial class LayoutStore : IDisposable
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
}
|
||||
|
||||
/// <summary>Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture
|
||||
/// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors
|
||||
/// to the box centre while it is dragged).</summary>
|
||||
public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1";
|
||||
|
||||
public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0");
|
||||
|
||||
/// <summary>Mono output (creator ruling 2026-10-30): folds the mix so single-sided audio is
|
||||
/// audible on both sides. Restored at startup — an accessibility switch that resets is a
|
||||
/// failure, not a default.</summary>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -104,7 +104,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
|
||||
|
||||
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
|
||||
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand and account surfaces remain at the sides. Its **right edge aligns with the layers panel below** (`TopBar` column 0 is a fixed **352** = work-area margin 8 + 360px column − the bar's own 16px padding), so the button and the panel share an edge. The **settings gear moved to the preview rail** (2026-11-01): first tab, orange (`Brush.Attention`), gear icon only — left-click opens the app menu (Settings / Bug / Feature / About), right-click goes straight to App Settings.
|
||||
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
|
||||
- **Preview gestures (2026-11-01):** *edit mode* — left selects/drags/resizes, right opens the context menu, **Ctrl+right toggles the zoom box**. *Preview mode* — **left toggles the zoom box**, right opens the context menu; **Ctrl+right does nothing at all** (no chord while live or recording — one hand, always). ⚠️ **Both the Down and the Up event must be handled for a Ctrl+right-click**: WPF's `PopupControlService` opens a context menu on right-button-**up**, so suppressing only the down event leaves the menu appearing under the gesture (found 2026-11-01 in edit mode). Menus exist on the webcam layer and the backdrop; every other layer right-clicks through to the backdrop menu. Left-dragging with the zoom box open moves it, in both modes. **Ctrl-down closes it**: pressing Ctrl while the box is open dismisses it, so the same hand that opened it can put it away (`MainWindow_PreviewKeyDown`; the box cannot cancel itself on creation because Ctrl is already down before it exists). **Follow mode** is a Settings pill, default **off = Anchor**: with Follow on, dragging re-anchors the capture to the box centre (`ZoomFollowsBox`, persisted as the `Zoom.FollowBox` setting). **The gesture activates the box at the cursor**, not merely dismisses it: `ActivateZoom` no longer refuses when idle (it used to return unless `IsLive || IsRecording`, and `ToggleZoom` only ever dismissed — so the gesture did nothing at all outside a session until 2026-11-01). The social bar consumes its own click (toggle top ⇄ bottom), and the preview's tunnelling handler steps aside for it — otherwise the bar is unclickable in preview mode and drags the layer beneath it in edit mode.
|
||||
- **Preview undo/redo (2026-11-01):** `Ctrl+Z` / `Ctrl+Shift+Z`, window-level bindings, live only while `EditingActive` — undo with the preview showing program output would rewrite a scene nobody is looking at. `Models/PreviewHistory` snapshots element **references** plus geometry, visibility, order and membership, so move / resize / reorder / add / remove / hide all undo in one step per gesture (`PushPreviewUndo` is called once at drag or resize start, never inside the move loop). Property edits (colour, opacity, border, font) are **not** captured yet — stated in the type's doc comment so nobody assumes otherwise. The stack clears on layout load. Guard: `ytLive.Tests/PreviewHistoryTests.cs`.
|
||||
- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build".
|
||||
- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition.
|
||||
|
||||
@@ -14,6 +14,9 @@ namespace ytLive.Tests;
|
||||
/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
|
||||
/// crash the app or quietly grant Pro.</para>
|
||||
/// </summary>
|
||||
// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
|
||||
// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class EntitlementTests
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Zoom box follow mode (creator ruling 2026-11-01): the Settings pill toggles between **Anchor**
|
||||
/// — the capture stays on the clicked point, which is the default and the 2026-10-08 model — and
|
||||
/// **Follow**, where dragging the box re-anchors the capture to the box centre so the magnifier
|
||||
/// shows whatever it hovers over.
|
||||
/// </summary>
|
||||
// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
|
||||
// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
|
||||
[Collection("MainViewModelLayoutPath")]
|
||||
public class ZoomFollowModeTests
|
||||
{
|
||||
private static MainViewModel NewVm(out string dbPath)
|
||||
{
|
||||
dbPath = Path.Combine(Path.GetTempPath(), $"llamacasty-zoom-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = dbPath;
|
||||
return new MainViewModel();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anchor_is_the_default_and_dragging_leaves_the_capture_alone()
|
||||
{
|
||||
var vm = NewVm(out var dbPath);
|
||||
try
|
||||
{
|
||||
Assert.False(vm.ZoomFollowsBox);
|
||||
|
||||
vm.ActivateZoom(1000, 500);
|
||||
var captureX = vm.ZoomState.CaptureX;
|
||||
|
||||
vm.StartZoomMove(1000, 500);
|
||||
vm.UpdateZoomMove(1200, 700);
|
||||
|
||||
Assert.Equal(captureX, vm.ZoomState.CaptureX);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(dbPath); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Follow_moves_the_capture_with_the_box()
|
||||
{
|
||||
var vm = NewVm(out var dbPath);
|
||||
try
|
||||
{
|
||||
vm.ActivateZoom(1000, 500);
|
||||
var anchoredCaptureX = vm.ZoomState.CaptureX;
|
||||
|
||||
vm.ZoomFollowsBox = true;
|
||||
vm.StartZoomMove(1000, 500);
|
||||
vm.UpdateZoomMove(1200, 700);
|
||||
|
||||
Assert.NotEqual(anchoredCaptureX, vm.ZoomState.CaptureX);
|
||||
// The capture is half the box, centred on the box: box centre minus a quarter of it.
|
||||
var expected = Math.Clamp(
|
||||
vm.ZoomState.X + vm.ZoomState.Width / 2 - vm.ZoomState.Width / 4,
|
||||
0, 1920 - vm.ZoomState.Width / 2);
|
||||
Assert.Equal(expected, vm.ZoomState.CaptureX, 3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
try { File.Delete(dbPath); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_choice_persists_in_settings()
|
||||
{
|
||||
// Persistence is the store's job, tested against the store directly: two live view models
|
||||
// in one process fight over the SQLite file and the frame pump, which is a property of the
|
||||
// fixture, not of the setting.
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"llamacasty-zoom-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using (var store = new ytLive.Services.LayoutStore(dbPath))
|
||||
{
|
||||
Assert.False(store.LoadZoomFollowBox());
|
||||
store.SaveZoomFollowBox(true);
|
||||
}
|
||||
|
||||
using (var reopened = new ytLive.Services.LayoutStore(dbPath))
|
||||
{
|
||||
Assert.True(reopened.LoadZoomFollowBox());
|
||||
reopened.SaveZoomFollowBox(false);
|
||||
}
|
||||
|
||||
using (var again = new ytLive.Services.LayoutStore(dbPath))
|
||||
Assert.False(again.LoadZoomFollowBox());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(dbPath); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user