Preview: promote selected layer for hit testing

Temporarily raise the selected preview item and prioritize it in the custom hit test without changing scene order. Refresh integration-test expectations for Brand logo layers and explicit thumbnail assets.

WPF references: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0 https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer
This commit is contained in:
2026-10-28 13:09:15 -07:00
parent 7da287244b
commit 769c616706
13 changed files with 326 additions and 79 deletions
+1 -1
View File
@@ -74,7 +74,7 @@
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
IsHitTestVisible="False" Visibility="Collapsed"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
<ItemsControl x:Name="OverlayElements" ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
+36 -8
View File
@@ -31,6 +31,7 @@ public partial class PreviewPane : UserControl
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
OverlayElements.ItemContainerGenerator.StatusChanged += (_, _) => UpdateSelectedLayerZOrder();
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
@@ -45,6 +46,7 @@ public partial class PreviewPane : UserControl
newVm.PropertyChanged += ViewModel_PropertyChanged;
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox();
UpdateSelectedLayerZOrder();
}
}
@@ -92,12 +94,27 @@ public partial class PreviewPane : UserControl
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.
@@ -439,7 +456,7 @@ public partial class PreviewPane : UserControl
return;
}
var hit = HitElement(vm, p);
var hit = HitElementAt(vm, p);
if (hit != null)
{
vm.SelectedElement = hit;
@@ -593,23 +610,34 @@ public partial class PreviewPane : UserControl
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
private SceneElement? HitElement(MainViewModel vm, Point p)
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 (!IsDraggableElement(element)) continue;
if (element is Source { IsEnabled: false }) continue;
if (element is WebcamSceneConfig { IsVisible: false }) continue;
if (p.X >= element.X && p.X <= element.X + element.Width &&
p.Y >= element.Y && p.Y <= element.Y + element.Height)
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;
@@ -645,4 +673,4 @@ public partial class PreviewPane : UserControl
ZoomVisualBrush.Visual = null;
}
}
}
}
+6 -6
View File
@@ -7,13 +7,13 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; direct right-click-to-settings is active uncommitted work. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
+62 -4
View File
@@ -1,6 +1,64 @@
# HANDOFF — 2026-10-26 (ai.md memory split, 3 units landed; see below — everything after this block is the prior 2026-10-22 state, still accurate)
# HANDOFF — 2026-10-28 (selected-layer preview stacking; suite green)
## At a glance (2026-10-26 docs session)
## Current state
- **Branch `main`; `origin/main` remains at `7ae30d4`.** The five
feature commits since that point are `90cd039` (fade starts from outgoing picture), `f67a263`
(transition picker), `b86b6bb` (Wipe), `841c905` (brand logo layer), and `7da2872` (preview and
program transitions). They are not pushed; no push was requested.
- **Four UI-redesign commits already on `origin/main` were missing from the older handoff:**
`24aca7d` moved the scene strip above the preview and removed the default CRT/on-air image seeds;
`3239808` added the Scene Layers header/count and layer icons plus scene-card names, hotkeys, and
PROGRAM badge; `a8814b4` added the explicit EDIT toggle and fixed the scene-card/left-column
sizing; `7ae30d4` moved readouts/resolution into the top-left cell, added the Socials rail drawer,
and restored webcam/chat border controls and vertical 1080p30. `b7ab169` added [`DESIGN.md`](DESIGN.md)
with exploratory color/type tokens; it explicitly is not a layout spec or a theme implementation.
`ai.md` and `Controls/index.md` now describe the landed surface. No new task was added to the queue.
- **Shipped in those commits:** type/duration picker persisted with the layout; CPU left-to-right
Wipe; one locked Brand logo image layer per scene; preview transition animation in every mode;
program transitions while recording or streaming. The 7da2872 commit omitted a memory update;
`TASKS.md` and `ai.md` now reflect it.
- **Current work unit:** when a draggable element is selected from the layer list, `PreviewPane`
temporarily raises its item container and gives it priority in the custom preview hit test. On
deselection, normal preview stacking returns; the scene collection order and saved/program z-order
never change. `SelectedLayerPreviewHitTestingTests` verifies the overlapping Brand logo/webcam
visual hit, custom hit priority, and deselection restore.
- **The six prior suite failures are resolved in their tests, not by reverting current behavior:**
LayerReorder now accounts for the permanent Brand logo after Background; BorderColorPicker validates
every palette picker and scopes its rendered-widget lookup to the visible webcam control; both
thumbnail cases persist explicit tile assets instead of relying on removed default seed files;
SourceNaming only considers user-created Text overlays; ZoomBox locates its placeholder by type,
not a list index changed by Brand logo.
- **The worktree already had three uncommitted user edits before this docs sync; they are preserved:**
`Controls/OverlayHost.xaml` groups App Settings into Recordings, Brand logo, Preview, and Scene
transition sections with compact controls; `Controls/TopBar.xaml` wires right-click on the gear;
`Controls/TopBar.xaml.cs` opens App Settings directly on right-click while left-click keeps the
menu. These are active UX-redesign work, not part of the selected-layer fix. On-screen verification is
still pending.
- **This work unit is committed locally, not pushed.** The three pre-existing user edits above
remain uncommitted and untouched.
- **Next:** continue the creator's UX redesign and verify the current App Settings/gear changes,
transitions, and Brand logo on-screen. The earlier vertical-webcam hands-on check is also still
pending unless the creator confirms it was completed. Move's persistent-layer behavior and Cube
remain queued; distribution remains deferred until near v1.
- **Verification state:** `verify.sh` passed: clean build with 0 warnings, **444/444 tests passed**,
and scope check passed. The new selected-layer integration test also passes by itself.
- **Follow-up, not fixed here:** `ytLive.csproj`'s `GenerateBuildStamp` comment still describes a
commit-count-based `+1 per commit` counter, but the target now reads/writes local `BuildCounter.txt`
and increments it per build. `ai.md` records the code's current behavior; correct the stale source
comment in a separately scoped change. `ViewModels/MainViewModel.Socials.cs` also still calls the
Socials surface a modal dialog in its method comment; the committed UI is now a preview-rail drawer.
Correct that source comment separately if the file is next in scope. The new deterministic WPF
hit-test recipe is in `MyMistakes.md`; the final commit cites its Microsoft reference.
- **WPF stacking reference:** [`Panel.ZIndex`](https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.panel.zindex?view=windowsdesktop-10.0)
governs preview container order. The code also prioritizes the selected element in `HitElementAt`,
because the custom hit test does not consult WPF's visual z-order. The deterministic test recipe and
hit-testing reference are recorded in `MyMistakes.md`; cite that Microsoft reference in the commit.
---
## Historical notes (retained reference; current checkout state is above)
### At a glance (2026-10-26 docs session)
- **`ai.md` is being split into `docs/ai/*.md`** (user-approved: verbatim move, staged units, "ai.md
only"). Three units landed as separate commits, **docs only, not pushed**:
`804b1d2` (frame pipeline: cadence/timing/budget/compositor/pump),
@@ -20,7 +78,7 @@
---
## Prior state (2026-10-22) — the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below
### Earlier detailed state (2026-10-22) — properties-panel asks shipped, incl. TASK 63, and pushed; vv webcam Units 1+2 state below
## At a glance
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
@@ -264,7 +322,7 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
*Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
the full top area and the circle option is not offered. **PASSES.**
## Next step
## Historical next step (2026-10-22; superseded by Current state above)
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
+16
View File
@@ -775,6 +775,22 @@ with `#Name`) + panel properties (`IsHitTestVisible/Opacity/children/actual size
`camera 'test-camera' failed` are test noise, not DB state — to check pollution, query
the DB directly (`python3 sqlite3`, `SELECT DeviceId FROM Webcam`), not the log.
## Deterministic test for a selected layer above an overlap (2026-10-28)
When preview rendering uses WPF z-order but dragging uses an additional custom hit-test, test both
contracts without injecting a physical cursor: in a real `MainWindow` on the `RealApp` STA, overlap
two scene elements, select the lower one through the layer list, assert `Panel.GetZIndex` raises its
container, assert `window.InputHitTest(point)` resolves the selected visual, and call the same
production hit-test method used by the drag handler to assert it chooses that selected model. Clear
selection and assert both hit paths return the original top layer and the scene's element order is
unchanged. WPF documents that input hit testing returns the input element at a point and that visual
hit-test results follow z-order: <https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/hit-testing-in-the-visual-layer>.
Do not use `SetCursorPos`/`mouse_event` for this specific assertion: the test passed alone but was
intermittent in the full suite, with a cursor move landing at the canvas edge (`X=1900`) rather than
the intended offset. Physical injection remains appropriate only when the behavior under test is the
actual OS mouse-routing/drag path (as in the layer-list reorder test).
## A "known failure" label without a recorded cause = a bug on life support
(2026-09-01, the audio triple-take) One line — `_delayedMix` (nullable, added by TASK 22,
+5 -3
View File
@@ -321,8 +321,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
- **⏸ TASK 48 / ALL distribution & packaging work — DEFERRED 2026-10-26 (creator ruling). Do not
start, plan, or re-litigate any of it until we are close to v1.** Verbatim reason: *"we're not
anywhere close to release just considering the remaining programming tasks"* and *"I am getting
ready to do a complete UX redesign"* — packaging, signing, installer and updater decisions would
be chasing UI and scope that are about to change, so the urgency is withdrawn, not the task.
ready to do a complete UX redesign"* — that redesign began 2026-10-26 (see [`HANDOFF.md`](HANDOFF.md)
for landed UI slices and current worktree). Packaging, signing, installer and updater decisions
would chase UI and scope that are still changing, so the urgency is withdrawn, not the task.
What is frozen:
- **TASK 48 items 6-8** (recording invariant comment, disqualifier re-check, pre-submission gates)
and the creator's items 4/5 (Certification Kit, Partner Center listing).
@@ -452,11 +453,12 @@ These were explicitly ruled out:
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
## Transitions & brand logo (queued 2026-10-27)
## Transitions & brand logo (status updated 2026-10-28)
- ✅ **Fade fix** — swaps fade from the outgoing picture (`TransitionFadeTests`). On-screen check pending.
- ✅ **Transition picker** in App Settings (type + duration), saved with the layout (`TransitionPickerTests`).
- ✅ **Wipe** — CPU blend, left-to-right reveal (`WipeTransitionTests`). Direction setting not yet added.
- ✅ **Transitions in edit mode and on output** — preview animates the outgoing picture for scene changes in every mode; program output transitions while recording or streaming. Fade/Move fade out, Wipe shrinks the outgoing picture, Cut is immediate. Guard: `PreviewTransitionTests`.
- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band.
- ✅ **Brand logo** — one locked "Brand logo" image layer per scene, directly above the background; file in
App Settings (imported to the asset table, the setting keeps the asset id); per-scene eye toggle; top-left
+22 -21
View File
File diff suppressed because one or more lines are too long
+16 -10
View File
@@ -69,16 +69,20 @@ public sealed class BorderColorPickerTests
// ── 2. the panel: the app's drop-down, no hand-rolled menu ───────────────
var xaml = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
var picker = xaml.Descendants()
var pickers = xaml.Descendants()
.Where(e => e.Name.LocalName == "ComboBox")
.SingleOrDefault(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}");
Assert.True(picker != null, "the border colour is not a drop-down over the palette");
Assert.True(((string?)picker!.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"the border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
.Where(c => (string?)c.Attribute("ItemsSource")
== "{Binding SelectedElement.BorderColorOptions}")
.ToArray();
Assert.NotEmpty(pickers);
foreach (var picker in pickers)
{
Assert.True(((string?)picker.Attribute("Style") ?? string.Empty)
.Contains("YtComboBox", StringComparison.Ordinal),
"a border-colour drop-down is not styled with YtComboBox");
Assert.Equal("{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}",
(string?)picker.Attribute("SelectedItem"));
}
var hexField = xaml.Descendants()
.Where(e => e.Name.LocalName == "TextBox")
@@ -146,7 +150,9 @@ public sealed class BorderColorPickerTests
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
var realCombo = FindVisualChildren<ComboBox>(panel)
.Single(c => c.ItemsSource is IEnumerable<NamedColor> opts && opts.Any(o => o.Hex == "#e94560"));
.Single(c => c.IsVisible
&& c.ItemsSource is IEnumerable<NamedColor> opts
&& opts.Any(o => o.Hex == "#e94560"));
realCombo.IsDropDownOpen = true;
realCombo.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
+8 -9
View File
@@ -83,7 +83,7 @@ public sealed class LayerReorderPersistenceTests
var vm = (MainViewModel)window.DataContext;
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
"3 seeded layers + the healed Background must load");
"3 seeded layers + the healed Background + Brand logo must load");
window.Show();
window.Activate();
@@ -94,12 +94,9 @@ public sealed class LayerReorderPersistenceTests
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
list.UpdateLayout();
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
// shifts container indices, but ItemContainerGenerator maps items to
// their (collapsed = zero-height) containers transparently.
// Brand logo is a permanent layer between Background and user layers.
// Physically drag ImgC above ImgA; the background and logo keep their
// relative positions while EndListDrag persists the user-layer reorder.
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
@@ -115,7 +112,9 @@ public sealed class LayerReorderPersistenceTests
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
Assert.Equal("Background", expected[0]);
Assert.Equal("ImgC", expected[1]);
Assert.Equal("Brand logo", expected[1]);
Assert.Equal("ImgC", expected[2]);
Assert.True(Array.IndexOf(expected, "ImgC") < Array.IndexOf(expected, "ImgA"));
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
@@ -466,4 +465,4 @@ public sealed class LayerReorderPersistenceTests
}
}
}
}
}
+27 -9
View File
@@ -15,9 +15,8 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's
/// top-strip tile is its own image, stored as an Asset row and seeded from the
/// creator's CRT-styled renders. The preview pane keeps rendering each scene's
/// Scene strip tile artwork (creator ruling 2026-10-10). A persisted scene tile
/// is stored as an Asset row; the preview pane keeps rendering each scene's
/// ORIGINAL background — the tiles never appear as preview backgrounds.
/// </summary>
[Collection("RealApp")]
@@ -28,7 +27,7 @@ public sealed class SceneThumbnailTests
public SceneThumbnailTests(RealAppHost app) => _app = app;
[Fact]
public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background()
public void Strip_Shows_The_Persisted_Tile_And_The_Preview_Keeps_The_Original_Background()
{
_app.Run(() =>
{
@@ -36,10 +35,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Starting] = file.TilePath;
env.SeedPaths[SceneCatalog.Live] = MissingPath();
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Starting);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var starting = SceneByName(vm, SceneCatalog.Starting);
@@ -91,7 +99,7 @@ public sealed class SceneThumbnailTests
}
[Fact]
public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder()
public void Live_Staged_Mini_Shows_Its_Persisted_Tile_Not_The_Capture_Placeholder()
{
_app.Run(() =>
{
@@ -99,9 +107,19 @@ public sealed class SceneThumbnailTests
try
{
env = ArrangedDb(out var file);
env.SeedPaths[SceneCatalog.Live] = file.TilePath;
env.Apply();
var tileAssetId = LayoutStore.Instance!.UpsertAsset(File.ReadAllBytes(file.TilePath), 320, 180);
using (var connection = new SqliteConnection($"Data Source={env.DbPath}"))
{
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "UPDATE Scene SET ThumbnailAssetId = $asset WHERE Name = $scene;";
cmd.Parameters.AddWithValue("$asset", tileAssetId);
cmd.Parameters.AddWithValue("$scene", SceneCatalog.Live);
Assert.Equal(1, cmd.ExecuteNonQuery());
}
var vm = new MainViewModel();
var live = SceneByName(vm, SceneCatalog.Live);
Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId));
@@ -355,4 +373,4 @@ public sealed class SceneThumbnailTests
private static string MissingPath()
=> Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png");
}
}
@@ -0,0 +1,118 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class SelectedLayerPreviewHitTestingTests
{
private readonly RealAppHost _app;
public SelectedLayerPreviewHitTestingTests(RealAppHost app) => _app = app;
[Fact]
public void ListSelectedBrandLogo_WinsPreviewHitTesting_UntilDeselected()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-selected-layer-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = null;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
var scene = vm.StagedScene!;
var logo = scene.Elements.OfType<Source>().Single(source => source.IsBrandLogo);
logo.X = 420;
logo.Y = 300;
logo.Width = 300;
logo.Height = 180;
var webcam = new WebcamSceneConfig
{
WebcamId = "test-camera",
Name = "Webcam",
X = logo.X,
Y = logo.Y,
Width = logo.Width,
Height = logo.Height,
};
scene.Elements.Add(webcam);
var originalOrder = scene.Elements.ToArray();
window.Show();
window.Activate();
window.Topmost = true;
window.UpdateLayout();
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var sourceList = (ListBox)leftPanel.FindName("SourceList")!;
var preview = (PreviewPane)window.FindName("PreviewPane")!;
var canvasGrid = (FrameworkElement)preview.FindName("CanvasGrid")!;
var overlayElements = (ItemsControl)preview.FindName("OverlayElements")!;
var logoContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(logo)!;
var webcamContainer = (UIElement)overlayElements.ItemContainerGenerator.ContainerFromItem(webcam)!;
var pointInCanvas = new Point(logo.X + 100, logo.Y + 60);
var pointInWindow = canvasGrid.TransformToVisual(window).Transform(pointInCanvas);
Assert.True(ReferenceEquals(webcam, ElementAt(window.InputHitTest(pointInWindow))));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(0, Panel.GetZIndex(logoContainer));
sourceList.SelectedItem = logo;
window.UpdateLayout();
Assert.True(ReferenceEquals(logo, vm.SelectedElement), "Selecting the layer-list row must select the brand logo.");
Assert.True(Panel.GetZIndex(logoContainer) > Panel.GetZIndex(webcamContainer));
Assert.Same(logo, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(logo, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
sourceList.SelectedItem = null;
window.UpdateLayout();
Assert.Null(vm.SelectedElement);
Assert.Equal(0, Panel.GetZIndex(logoContainer));
Assert.Same(webcam, ElementAt(window.InputHitTest(pointInWindow)));
Assert.Same(webcam, preview.HitElementAt(vm, pointInCanvas));
Assert.Equal(originalOrder, scene.Elements);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
private static SceneElement? ElementAt(System.Windows.IInputElement? hit)
{
DependencyObject? current = hit as DependencyObject;
while (current != null)
{
if (current is FrameworkElement { DataContext: SceneElement element })
return element;
current = current is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return null;
}
}
+3 -3
View File
@@ -44,8 +44,8 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
// The locked Background layer is always present but never numbered.
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
// Persistent scene layers are not part of the user-created Text sequence.
var names = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
@@ -53,7 +53,7 @@ public sealed class SourceNamingTests
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
var survivors = scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.TextOverlay)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
+6 -5
View File
@@ -49,8 +49,9 @@ public sealed class ZoomBoxInteractionTests
window.Show();
window.UpdateLayout();
Assert.NotNull(vm.StagedScene);
// The healed zoom placeholder sits at z-index 1, inert.
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
// The healed zoom placeholder is present and inert; persistent scene
// layers may precede it, so locate it by type rather than list index.
var placeholder = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.False(placeholder.IsZoomActive);
Assert.Equal(ElementKind.Static, placeholder.Kind);
@@ -137,7 +138,7 @@ public sealed class ZoomBoxInteractionTests
Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
Assert.Equal(captureX, vm.ZoomState.CaptureX);
Assert.Equal(captureY, vm.ZoomState.CaptureY);
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene.Elements[1]);
var zoom = vm.StagedScene.Elements.OfType<ZoomSceneElement>().Single();
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
Assert.Equal(captureX, zoom.CaptureX, 3);
@@ -179,7 +180,7 @@ public sealed class ZoomBoxInteractionTests
private static void AssertTrueActiveLayer(MainViewModel vm)
{
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
var zoom = vm.StagedScene!.Elements.OfType<ZoomSceneElement>().Single();
Assert.True(zoom.IsZoomActive);
Assert.True(zoom.IsVisible);
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
@@ -191,4 +192,4 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
}
}
}