From 7bc70872d9cf53f3da857ce32667ae183006f136 Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 6 Aug 2026 15:05:21 -0700 Subject: [PATCH] Round clip: true circle (Viewbox/1x1 ellipse) + grabbable corner handle (transparent source grid, non-hit-testable selection overlay); resize locks square in round mode --- MainWindow.xaml | 47 ++++---- MainWindow.xaml.cs | 2 +- ai.md | 12 +- ytLive.Tests/RoundClipInteractionTests.cs | 132 ++++++++++++++++++++++ 4 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 ytLive.Tests/RoundClipInteractionTests.cs diff --git a/MainWindow.xaml b/MainWindow.xaml index 6ee6c7e..cae6c48 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -8,7 +8,7 @@ mc:Ignorable="d" Title="{Binding WindowTitle}" Height="720" Width="1280" MinWidth="1180" MinHeight="600" - Icon="/Assets/llama-logo-icon.png" + Icon="/ytLive;component/Assets/llama-logo-icon.png" Background="#1a1a2e" WindowStartupLocation="CenterScreen" Closing="MainWindow_Closing" @@ -323,6 +323,7 @@ @@ -348,25 +349,29 @@ - - - - - - - - + + + + + + + + + + + + @@ -687,7 +692,7 @@ - diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 5d09f14..cb8017e 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -153,7 +153,7 @@ public partial class MainWindow : Window if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel)) { _isResizing = true; - _resizeAspect = sel.Width / Math.Max(1, sel.Height); + _resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height); grid.CaptureMouse(); e.Handled = true; return; diff --git a/ai.md b/ai.md index a63132f..be70fe6 100644 --- a/ai.md +++ b/ai.md @@ -110,8 +110,16 @@ C# / WPF (.NET 8) following MVVM: device never drowns the render thread. - **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored` (`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an - `Ellipse` for Round); toggled from the source chip; persisted in the layout DB. Round hit-testing is the - same rectangle as Traditional (selection overlay is rectangular) — acceptable for now. + `Ellipse` for Round); toggled from the source chip; persisted in the layout DB. + - The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders + as a true circle (diameter = the shorter source dimension) instead of an oval stretched to the + source rect — and the traditional `Image` keeps `UniformToFill` over the full rect. + - Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`. + - **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in + the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected + the source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"` + (whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is + `IsHitTestVisible="False"` so it never intercepts the click. - **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred to the encoder task. diff --git a/ytLive.Tests/RoundClipInteractionTests.cs b/ytLive.Tests/RoundClipInteractionTests.cs new file mode 100644 index 0000000..c1379f8 --- /dev/null +++ b/ytLive.Tests/RoundClipInteractionTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Shapes; +using System.Windows.Threading; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Reproduces the real MainWindow preview to check the round-clip corner +/// handle: whether a click at the corner actually stays inside the preview +/// (doesn't fall through and deselect the source) and whether the round clip +/// renders as a circle rather than an oval. +/// +public sealed class RoundClipInteractionTests +{ + [Fact] + public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle() + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + Run(); + } + catch (Exception ex) + { + failure = ex; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (failure != null) + throw new Xunit.Sdk.XunitException("Round-clip interaction failed: " + failure); + } + + private void Run() + { + var app = new App(); + app.InitializeComponent(); + + var window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + try + { + window.Show(); + window.UpdateLayout(); + + var scene = vm.ActiveScene!; + var source = new Source + { + Name = "Webcam", + Type = SourceType.Webcam, + DeviceId = "test-camera", + X = 1408, + Y = 778, + Width = 480, + Height = 270, + }; + scene.Sources.Add(source); + vm.SelectedSource = source; + window.UpdateLayout(); + + var previewGrid = (Grid)window.FindName("PreviewGrid")!; + var canvasGrid = (Grid)window.FindName("CanvasGrid")!; + var toWindow = canvasGrid.TransformToVisual(window); + + var corner = new Point(source.X + source.Width, source.Y + source.Height); + + foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round }) + { + source.ClipShape = shape; + window.UpdateLayout(); + + var cornerInWindow = toWindow.Transform(corner); + + // A click on the corner must land inside the preview (descendant + // of PreviewGrid); otherwise Window_PreviewMouseLeftButtonDown + // deselects the source and the handle can never be grabbed. + var hit = VisualTreeHelper.HitTest(window, cornerInWindow); + var grabbable = hit != null && IsDescendantOf(hit.VisualHit, previewGrid); + + Assert.True(grabbable, + $"{shape}: corner click fell through to '{hit?.VisualHit.GetType().Name ?? "null"}' " + + "so the source gets deselected before the resize handler runs"); + } + + // The round clip must render as a circle (square bounding box), not an oval. + source.ClipShape = ClipShape.Round; + window.UpdateLayout(); + var ellipse = FindRoundEllipse(window); + Assert.NotNull(ellipse); + Assert.Equal(ellipse!.RenderSize.Width, ellipse.RenderSize.Height, 1.0); + } + finally + { + window.Close(); + } + } + + private static Ellipse? FindRoundEllipse(Window window) + { + return Walk(window, element => element is Ellipse e && e.IsVisible) as Ellipse; + } + + private static DependencyObject? Walk(DependencyObject parent, Func predicate) + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (predicate(child)) return child; + var found = Walk(child, predicate); + if (found != null) return found; + } + return null; + } + + private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor) + { + while (child != null && !ReferenceEquals(child, ancestor)) + child = VisualTreeHelper.GetParent(child); + return child != null; + } +}