diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml index 012e1cf..de535fd 100644 --- a/Controls/OverlayHost.xaml +++ b/Controls/OverlayHost.xaml @@ -112,6 +112,17 @@ Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/> + + + + + + + diff --git a/MainWindow.xaml b/MainWindow.xaml index c179103..4dea0e7 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -19,6 +19,7 @@ Loaded="MainWindow_Loaded" Activated="MainWindow_Activated" Deactivated="MainWindow_Deactivated" + PreviewKeyDown="MainWindow_PreviewKeyDown" PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown" ContextMenuOpening="Window_ContextMenuOpening"> diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 0725640..140a408 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -162,6 +162,22 @@ public partial class MainWindow : Window { } + /// + /// 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. + /// + 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 diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index de6a4b7..a1d302d 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -45,6 +45,13 @@ public partial class LayoutStore : IDisposable UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString()); } + /// 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). + public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1"; + + public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0"); + /// 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. diff --git a/ViewModels/MainViewModel.Zoom.cs b/ViewModels/MainViewModel.Zoom.cs index c8c5310..0d8923c 100644 --- a/ViewModels/MainViewModel.Zoom.cs +++ b/ViewModels/MainViewModel.Zoom.cs @@ -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). /// + /// + /// 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. + /// + 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().FirstOrDefault(e => e.IsZoomActive); + if (zoomTarget != null) + { + zoomTarget.CaptureX = _zoomState.CaptureX; + zoomTarget.CaptureY = _zoomState.CaptureY; + } + if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene); + OnPropertyChanged(nameof(ZoomState)); + } + } + } + + /// 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). + 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(); } + /// Point the capture window at the box's current centre. The capture is half the + /// box's size, exactly as computes it, and clamped to the master + /// frame independently so an edge drag can keep the box on screen and the capture legal. + 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) diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 01dcdf7..b724273 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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(); diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md index 2bbb7f2..ead2eb2 100644 --- a/docs/ai/ui-surface.md +++ b/docs/ai/ui-surface.md @@ -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. diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs index 753b273..1c3838f 100644 --- a/ytLive.Tests/EntitlementTests.cs +++ b/ytLive.Tests/EntitlementTests.cs @@ -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. /// +// 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] diff --git a/ytLive.Tests/ZoomFollowModeTests.cs b/ytLive.Tests/ZoomFollowModeTests.cs new file mode 100644 index 0000000..f892dd6 --- /dev/null +++ b/ytLive.Tests/ZoomFollowModeTests.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using Xunit; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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. +/// +// 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) { } + } + } +}