Round clip: true circle (Viewbox/1x1 ellipse) + grabbable corner handle (transparent source grid, non-hit-testable selection overlay); resize locks square in round mode
This commit is contained in:
+7
-2
@@ -8,7 +8,7 @@
|
|||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="{Binding WindowTitle}" Height="720" Width="1280"
|
Title="{Binding WindowTitle}" Height="720" Width="1280"
|
||||||
MinWidth="1180" MinHeight="600"
|
MinWidth="1180" MinHeight="600"
|
||||||
Icon="/Assets/llama-logo-icon.png"
|
Icon="/ytLive;component/Assets/llama-logo-icon.png"
|
||||||
Background="#1a1a2e"
|
Background="#1a1a2e"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Closing="MainWindow_Closing"
|
Closing="MainWindow_Closing"
|
||||||
@@ -323,6 +323,7 @@
|
|||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Grid Width="{Binding Width}" Height="{Binding Height}"
|
<Grid Width="{Binding Width}" Height="{Binding Height}"
|
||||||
Opacity="{Binding Opacity}"
|
Opacity="{Binding Opacity}"
|
||||||
|
Background="Transparent"
|
||||||
RenderTransformOrigin="0.5,0.5">
|
RenderTransformOrigin="0.5,0.5">
|
||||||
<Grid.RenderTransform>
|
<Grid.RenderTransform>
|
||||||
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
|
||||||
@@ -348,6 +349,8 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Image.Style>
|
</Image.Style>
|
||||||
</Image>
|
</Image>
|
||||||
|
<Viewbox Stretch="Uniform">
|
||||||
|
<Grid Width="1" Height="1">
|
||||||
<Ellipse>
|
<Ellipse>
|
||||||
<Ellipse.Style>
|
<Ellipse.Style>
|
||||||
<Style TargetType="Ellipse">
|
<Style TargetType="Ellipse">
|
||||||
@@ -368,6 +371,8 @@
|
|||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
</Ellipse>
|
</Ellipse>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Viewbox>
|
||||||
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
@@ -687,7 +692,7 @@
|
|||||||
<!-- About -->
|
<!-- About -->
|
||||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||||
<Image Source="/Assets/llama-logo.png" Height="140" Stretch="Uniform"
|
<Image Source="/ytLive;component/Assets/llama-logo.png" Height="140" Stretch="Uniform"
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||||
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
|
<TextBlock Text="ytLlive" FontSize="22" FontWeight="Bold" Foreground="#e94560"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ public partial class MainWindow : Window
|
|||||||
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
|
if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||||
{
|
{
|
||||||
_isResizing = true;
|
_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();
|
grid.CaptureMouse();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -110,8 +110,16 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
device never drowns the render thread.
|
device never drowns the render thread.
|
||||||
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
|
||||||
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
|
(`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
|
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB.
|
||||||
same rectangle as Traditional (selection overlay is rectangular) — acceptable for now.
|
- 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
|
- **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
|
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
|
||||||
to the encoder task.
|
to the encoder task.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<DependencyObject, bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user