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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user