using System; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Effects; using System.Windows.Media.Animation; using System.Windows.Shapes; using System.Windows.Threading; using ytLive.Helpers; using ytLive.Models; using ytLive.ViewModels; namespace ytLive.Controls; public partial class PreviewPane : UserControl { private bool _isDraggingOverlay; private bool _isResizing; private bool _isZoomDragging; private Point _grabOffset; private double _resizeAspect; private readonly System.Collections.Generic.Dictionary _activeBorderAnimations = new(); private DispatcherTimer? _socialGlowTimer; private double _socialGlowAngle; public PreviewPane() { InitializeComponent(); DataContextChanged += PreviewPane_DataContextChanged; OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder(); } private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) { if (e.OldValue is MainViewModel oldVm) { oldVm.PropertyChanged -= ViewModel_PropertyChanged; oldVm.PreviewTransition -= PlayPreviewTransition; } if (e.NewValue is MainViewModel newVm) { newVm.PropertyChanged += ViewModel_PropertyChanged; newVm.PreviewTransition += PlayPreviewTransition; UpdateZoomBox(); UpdateSelectedLayerZOrder(); } } /// Animates the outgoing picture away over the new scene: a fade for Fade and Move, /// a right-to-left shrink for Wipe. Cut plays nothing. private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds) { TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null); TransitionOverlay.Clip = null; if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0) { TransitionOverlay.Visibility = Visibility.Collapsed; return; } var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); snapshot.Render(PreviewCanvas); TransitionOverlay.Source = snapshot; TransitionOverlay.Opacity = 1; TransitionOverlay.Visibility = Visibility.Visible; var duration = TimeSpan.FromMilliseconds(milliseconds); if (type == ytLive.Services.TransitionType.Wipe) { var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080)); TransitionOverlay.Clip = clip; var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration) { FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop, }; reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed; clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal); } else { var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration) { FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop, }; fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed; TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade); } } private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == nameof(MainViewModel.SelectedElement)) UpdateSelectedLayerZOrder(); if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape)) { UpdateZoomBox(); } } private void UpdateSelectedLayerZOrder() { var selected = (DataContext as MainViewModel)?.SelectedElement; if (selected != null && !IsDraggableElement(selected)) selected = null; for (var i = 0; i < OverlayElements.Items.Count; i++) { if (OverlayElements.ItemContainerGenerator.ContainerFromIndex(i) is UIElement container) Panel.SetZIndex(container, ReferenceEquals(OverlayElements.Items[i], selected) ? 1 : 0); } } // Static images and the webcam share the move/resize/selection behavior. public static bool IsDraggableElement(SceneElement element) => element.IsDraggable; public void UpdateSelectionOverlay(bool selected) { SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed; } // ─── Clicking inside the preview / pull-out drawer (used by the window's // Window_PreviewMouseLeftButtonDown deselection logic) ─── public bool IsClickInsidePreview(DependencyObject original) => IsDescendantOf(original, PreviewGrid); public bool IsClickInsideDrawer(DependencyObject original) => IsDescendantOf(original, TextPullOutHost); private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor) { while (child != null && !ReferenceEquals(child, ancestor)) { child = child is Visual or System.Windows.Media.Media3D.Visual3D ? VisualTreeHelper.GetParent(child) : LogicalTreeHelper.GetParent(child); } return child != null; } // ─── Audio meters + TRAX + socials (ROW 2 of the preview pane) ─── private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is Slider slider) SetSliderValueFromClick(slider, e); if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetVolumeAdjusting(true); } private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetVolumeAdjusting(false); } private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e) { if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetVolumeAdjusting(false); } private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is Slider slider) SetSliderValueFromClick(slider, e); if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetGameVolumeAdjusting(true); } private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetGameVolumeAdjusting(false); } private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e) { if (sender is FrameworkElement { DataContext: MainViewModel vm }) vm.SetGameVolumeAdjusting(false); } private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e) { if (FindParent(e.OriginalSource as Visual) != null) return; var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track; if (track == null) return; var pos = e.GetPosition(track); var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1); slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum); e.Handled = true; } private static T? FindParent(Visual? visual) where T : Visual { while (visual != null) { if (visual is T match) return match; visual = VisualTreeHelper.GetParent(visual) as Visual; } return null; } private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement { DataContext: MainViewModel vm }) { vm.ToggleGameMuteCommand.Execute(null); } } // TRAX (TASK 9): left-click toggles play/pause (picks a track when none is // loaded); right-click always opens the picker. The picker is the OS file // dialog — the "never the OS viewer" rule covers the preview, not pickers. // ─── Webcam / background context menus (right-click on overlay elements) ─── private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e) => (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null); private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e) { if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag } || !Enum.TryParse(tag, out var animation)) return; element.BorderAnimation = animation; StopBorderAnimation(element.Id); if (animation == BorderAnimation.None) return; var shape = FindBorderShape(sender as DependencyObject); if (shape == null) return; var sb = CreateBorderAnimation(animation, shape); if (sb == null) return; sb.Freeze(); _activeBorderAnimations[element.Id] = sb; shape.BeginAnimation(Shape.OpacityProperty, null); Storyboard.SetTarget(sb, shape); sb.Begin(); } private void StopBorderAnimation(string elementId) { if (_activeBorderAnimations.TryGetValue(elementId, out var old)) { old.Stop(); _activeBorderAnimations.Remove(elementId); } } private static Shape? FindBorderShape(DependencyObject? start) { var current = start; while (current != null) { if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target) { for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++) { var child = VisualTreeHelper.GetChild(target, i); if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible) return s; } return null; } current = VisualTreeHelper.GetParent(current); } return null; } private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape) { var sb = new Storyboard(); switch (animation) { case BorderAnimation.Pulse: AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true); break; case BorderAnimation.Glow: AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true); break; case BorderAnimation.Shimmer: AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true); break; case BorderAnimation.Electricity: AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true); break; case BorderAnimation.Sparkles: AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true); break; case BorderAnimation.MarchingAnts: shape.StrokeDashArray = new DoubleCollection { 4, 2 }; AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false); break; case BorderAnimation.Chase: shape.StrokeDashArray = new DoubleCollection { 8, 4 }; AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false); break; case BorderAnimation.Rainbow: if (shape.Stroke is SolidColorBrush brush) { var frozen = brush.IsFrozen; if (frozen) brush = brush.Clone(); shape.Stroke = brush; var colorAnim = new ColorAnimationUsingKeyFrames { Duration = new Duration(TimeSpan.FromSeconds(3)), RepeatBehavior = RepeatBehavior.Forever }; colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero))); colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5)))); colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1)))); colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5)))); colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2)))); colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5)))); Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)")); sb.Children.Add(colorAnim); } break; default: return null; } return sb.Children.Count > 0 ? sb : null; } private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop, double from, double to, Duration duration, bool autoReverse) { var anim = new DoubleAnimation(from, to, duration) { AutoReverse = autoReverse, RepeatBehavior = RepeatBehavior.Forever }; Storyboard.SetTargetProperty(anim, new PropertyPath(prop)); sb.Children.Add(anim); } /// The border-colour submenu is BUILT from the palette on open: the list of /// offered colours lives once, in (TASK 63's rule — /// a picker never carries its own copy). The generated rows inherit this menu's /// DataContext (the ), so the click handler is unchanged. private void WebcamBorderColorMenu_SubmenuOpened(object sender, RoutedEventArgs e) { if (sender is not MenuItem { DataContext: SceneElement element } menu) return; menu.Items.Clear(); var none = new MenuItem { Header = "None", Tag = string.Empty, IsCheckable = true, IsChecked = string.IsNullOrEmpty(element.BorderColor) }; none.Click += WebcamMenu_SetBorderColor; menu.Items.Add(none); foreach (var colour in BorderColorPalette.All) { var row = new MenuItem { Header = colour.ToString(), Tag = colour.Hex, IsCheckable = true, IsChecked = string.Equals(element.BorderColor, colour.Hex, StringComparison.OrdinalIgnoreCase), }; row.Click += WebcamMenu_SetBorderColor; menu.Items.Add(row); } } private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e) { if (sender is MenuItem { DataContext: SceneElement element, Tag: string color }) element.BorderColor = color; } private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e) { if (sender is MenuItem { DataContext: SceneElement element }) element.IsVisible = false; } private void WebcamMenu_Remove(object sender, RoutedEventArgs e) { if (sender is MenuItem { DataContext: SceneElement element }) (DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element); } // ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was // unusable for shaky hands (jitter around the direction deadzone), so the bar // rides on {Binding SocialBarTop} alone and a click flips it. ─── private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { (DataContext as MainViewModel)?.ToggleSocialBarPosition(); e.Handled = true; } /// Is this canvas point inside the social bar? It is a full-width strip at the top or /// bottom of the frame and it consumes its own click, so the preview's tunnelling handler has /// to step aside (see Preview_MouseLeftButtonDown). private bool IsOverSocialBar(Point canvasPoint) { if (DataContext is not MainViewModel { SocialBarVisible: true }) return false; if (SocialBarElement.ActualHeight <= 0) return false; var topLeft = SocialBarElement.TranslatePoint(new Point(0, 0), CanvasGrid); return new Rect(topLeft, SocialBarElement.RenderSize).Contains(canvasPoint); } private void SocialsButton_MouseEnter(object sender, MouseEventArgs e) { if (SocialBarElement == null) return; // Kick the social bar to full brightness so it's immediately visible var effect = SocialBarElement.Effect as DropShadowEffect; if (effect != null) { effect.BlurRadius = 40; effect.Opacity = 1.0; } // Pulse the glow so it catches the eye _socialGlowAngle = 0; _socialGlowTimer?.Stop(); _socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) }; _socialGlowTimer.Tick += SocialGlowTick; _socialGlowTimer.Start(); } private void SocialsButton_MouseLeave(object sender, MouseEventArgs e) { _socialGlowTimer?.Stop(); _socialGlowTimer = null; if (SocialBarElement?.Effect is DropShadowEffect effect) { effect.BlurRadius = 18; effect.Opacity = 0.9; } } private void SocialGlowTick(object? sender, EventArgs e) { if (SocialBarElement?.Effect is not DropShadowEffect effect) return; _socialGlowAngle += 0.18; if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2; // Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0 double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1 effect.BlurRadius = 24 + t * 24; effect.Opacity = 0.7 + t * 0.3; } // ─── Preview: element selection / move / resize / hit-testing ─── private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var vm = DataContext as MainViewModel; if (vm is null) return; var grid = (Grid)sender; var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse; var p = toCanvas.Transform(e.GetPosition(grid)); // The social bar owns its own left-click (toggle top ⇄ bottom). This handler is the // TUNNELLING preview event, so it fires before the bar's bubbling handler: without this // guard the bar would be unclickable in preview mode (zoom would win) and, in edit mode, // it would drag whatever layer sat underneath it. if (IsOverSocialBar(p)) return; // Preview mode (EDIT off): the left button drives the zoom box — the common gesture in // the mode where nothing is editable. Dragging it afterwards is handled by MouseMove. if (!vm.IsEditMode) { vm.ToggleZoom(); e.Handled = true; return; } var selected = vm.SelectedElement; if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel)) { vm.PushPreviewUndo(); _isResizing = true; _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height); grid.CaptureMouse(); e.Handled = true; return; } var hit = HitElementAt(vm, p); if (hit != null) { vm.SelectedElement = hit; // One snapshot per gesture: pushing inside the move loop would fill the stack with // a hundred one-pixel steps. vm.PushPreviewUndo(); _isDraggingOverlay = true; _grabOffset = new Point(p.X - hit.X, p.Y - hit.Y); grid.CaptureMouse(); e.Handled = true; return; } vm.SelectedElement = null; } private void Preview_MouseMove(object sender, MouseEventArgs e) { var vm = DataContext as MainViewModel; if (vm is null) return; var grid = (Grid)sender; var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse; var p = toCanvas.Transform(e.GetPosition(grid)); if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive) { if (!_isZoomDragging) { vm.StartZoomMove(p.X, p.Y); _isZoomDragging = true; grid.CaptureMouse(); } else { vm.UpdateZoomMove(p.X, p.Y); } return; } if (!vm.IsEditMode) return; if (!_isDraggingOverlay && !_isResizing) return; var selected = vm.SelectedElement; if (selected == null) { EndPreviewDrag(grid); return; } if (_isDraggingOverlay) { // TASK 57: under the vertical tier the camera snaps into the // cropped band (one of six spots) instead of free-floating over // the master — outside the band it is not in the stream at all. if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam) { vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y); } else { var minX = -(selected.Width - 20); var maxX = 1920 - 20; var minY = -(selected.Height - 20); var maxY = 1080 - 20; selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX)); selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY)); } } else if (_isResizing) { var isWebcam = selected is WebcamSceneConfig; var isRound = selected.ClipShape == ClipShape.Round; var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift); var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(vm.StagedScene?.Name) : 1920; var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080; var newW = Math.Clamp(p.X - selected.X, 32, maxW); var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH); if (proportional && newH > maxH) { newH = maxH; newW = newH * _resizeAspect; } selected.Width = newW; selected.Height = newH; // TASK 57: inside the vertical band the size snaps (the Chat // camera has two sizes: the small tile or one whole area) and the // camera ends up on its spot. if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam) vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH); } } private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (_isZoomDragging && DataContext is MainViewModel vm) { vm.EndZoomMove(); _isZoomDragging = false; ((Grid)sender).ReleaseMouseCapture(); e.Handled = true; return; } EndPreviewDrag((Grid)sender); } private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e) { // Right-click = the context menu, in both modes (creator ruling 2026-11-01). // Ctrl+right-click = the zoom box, EDIT MODE ONLY: the creator asked for no two-handed // chords while live or recording, so the preview swallows Ctrl+right-click instead of // letting it fall through to the menu. Returning without Handled is what lets WPF's // ContextMenuService open the menu under the cursor. var vm = DataContext as MainViewModel; if (vm is null) return; if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) return; if (!vm.IsEditMode) { e.Handled = true; return; } vm.ToggleZoom(); e.Handled = true; } private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e) { if (_isZoomDragging) { var grid = (Grid)sender; grid.ReleaseMouseCapture(); _isZoomDragging = false; if (DataContext is MainViewModel vm) vm.EndZoomMove(); e.Handled = true; return; } // WPF's PopupControlService opens a context menu on right-button-UP, not down — so // handling the Down event alone still let a menu appear under a Ctrl+right-click (found // by the creator in edit mode, 2026-11-01). Swallow the Up event too, in both modes: // Down decides the action, Up must not undo that decision. if ((Keyboard.Modifiers & ModifierKeys.Control) != 0) e.Handled = true; } private void EndPreviewDrag(Grid grid) { if (_isDraggingOverlay || _isResizing || _isZoomDragging) grid.ReleaseMouseCapture(); _isDraggingOverlay = false; _isResizing = false; _isZoomDragging = false; if (DataContext is MainViewModel vm) vm.EndZoomMove(); } private bool HitHandle(Point mouseScreen, SceneElement element) { var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height)); return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20; } internal SceneElement? HitElementAt(MainViewModel vm, Point p) { var scene = vm.StagedScene; if (scene == null) return null; // A layer selected from the list temporarily wins both preview stacking and hit-testing; // the scene's persistent element order remains unchanged. if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p)) return selected; for (var i = scene.Elements.Count - 1; i >= 0; i--) { var element = scene.Elements[i]; if (IsHitTestable(element) && Contains(element, p)) return element; } return null; } private static bool IsHitTestable(SceneElement element) => IsDraggableElement(element) && element is not Source { IsEnabled: false } && element is not WebcamSceneConfig { IsVisible: false }; private static bool Contains(SceneElement element, Point point) => point.X >= element.X && point.X <= element.X + element.Width && point.Y >= element.Y && point.Y <= element.Y + element.Height; private void UpdateZoomBox() { if (DataContext is not MainViewModel vm) return; if (vm.IsZoomActive && ZoomVisualBrush != null) { // The preview mirrors the output zoom layer: sample the WHOLE composed // preview (CanvasGrid = backdrop + every element layer — the same all-layer // source the output compositor magnifies) at the FIXED capture window (the // box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box // only moves the ZoomBox element; the Viewbox stays glued to the capture. ZoomVisualBrush.Visual = CanvasGrid; ZoomVisualBrush.Viewbox = new Rect( vm.ZoomState.CaptureX, vm.ZoomState.CaptureY, vm.ZoomState.Width / 2, vm.ZoomState.Height / 2); ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute; if (vm.ZoomShape == Models.ZoomShape.Circle) { ZoomCircleOutline.Visibility = Visibility.Visible; ZoomRectOutline.Visibility = Visibility.Collapsed; ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height)); } else { ZoomCircleOutline.Visibility = Visibility.Collapsed; ZoomRectOutline.Visibility = Visibility.Visible; ZoomBox.Clip = null; } } else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid)) { ZoomVisualBrush.Visual = null; } } /// Rail settings tab (creator ruling 2026-11-01): left-click opens the same app menu /// the top-bar gear used to own, so Bug / Feature / About keep their only entry point. private void SettingsTab_Click(object sender, MouseButtonEventArgs e) { if (sender is not FrameworkElement { ContextMenu: { } menu } anchor) return; menu.PlacementTarget = anchor; menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Left; menu.IsOpen = true; e.Handled = true; } /// Right-click goes straight to App Settings — the same shortcut the old gear had. private void SettingsTab_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { e.Handled = true; if (DataContext is MainViewModel { OpenSettingsCommand: { } open } && open.CanExecute(null)) open.Execute(null); } }