TASK 63: the border-colour picker IS the app's own drop-down
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:
"why does the drop down under the web cam properties not show the same style for
the border color drop down list box?"
"one of the first things we did was define branding. We established look-and-feel
standards. And yet you keep ignoring these and I'm wondering why"
WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".
WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
click handler was the exact anti-pattern the theme forbids. The swatch also carries
a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
(the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
honest answer). The palette's name moved out of TASK 62's TextBlock into the
drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
WebcamBorderColorPicker_Click are gone.
MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.
Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).
Full suite: 435/435 passed, 0 warnings.
This commit is contained in:
+47
-14
@@ -3,6 +3,37 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:models="clr-namespace:ytLive.Models">
|
||||
<UserControl.Resources>
|
||||
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
|
||||
colour entries are drawn by TEMPLATES, in the theme's colours and with the
|
||||
theme's HexToBrush converter — no hand-built menu, no hand-painted brush.
|
||||
The closed box shows the swatch + the colour's NAME; the rows show the
|
||||
swatch + the hex + the name. -->
|
||||
<DataTemplate x:Key="BorderColorClosedTemplate">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||
BorderBrush="#2a3a5e" BorderThickness="1"/>
|
||||
<TextBlock Text="{Binding Name}" Margin="6,0,0,0" FontSize="12"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="BorderColorRowTemplate">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||
BorderBrush="#2a3a5e" BorderThickness="1"/>
|
||||
<TextBlock Text="{Binding Hex}" Margin="6,0,0,0" FontSize="12"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Name}" Margin="8,0,0,0" FontSize="12"
|
||||
Foreground="#a0a0b0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<Helpers:ClosedBoxTemplateSelector x:Key="BorderColorTemplates"
|
||||
ClosedTemplate="{StaticResource BorderColorClosedTemplate}"
|
||||
RowTemplate="{StaticResource BorderColorRowTemplate}"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border Background="#16213e" CornerRadius="6" Padding="12">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
@@ -297,21 +328,23 @@
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<!-- TASK 63: the border colour is picked with the app's OWN
|
||||
drop-down (YtComboBox) — the same widget the Display
|
||||
and Font pickers use — not a hand-rolled menu. The hex
|
||||
field stays for a colour the creator types; the
|
||||
drop-down carries the colour's name. -->
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
||||
ToolTip="Pick color"/>
|
||||
<!-- TASK 62: the hex field keeps its own fixed width so the
|
||||
colour's NAME can sit right next to it, exactly as it
|
||||
reads in the drop-down list. Empty for a custom hex. -->
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
MaxLength="7" ToolTip="#RRGGBB" Width="60"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderColorName}"
|
||||
Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBox DockPanel.Dock="Left"
|
||||
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
MaxLength="7" ToolTip="#RRGGBB" Width="60"
|
||||
VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
|
||||
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
|
||||
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
ToolTip="Border colour" MinWidth="100"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
|
||||
@@ -241,50 +241,6 @@ public partial class LeftPanel : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
|
||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
||||
{
|
||||
var menu = new System.Windows.Controls.ContextMenu();
|
||||
foreach (var swatch in BorderColorPalette.All)
|
||||
{
|
||||
// TASK 60: the hex stays, and the colour's NAME goes next to it.
|
||||
var header = new System.Windows.Controls.StackPanel
|
||||
{
|
||||
Orientation = System.Windows.Controls.Orientation.Horizontal
|
||||
};
|
||||
header.Children.Add(new System.Windows.Controls.TextBlock { Text = swatch.Hex });
|
||||
header.Children.Add(new System.Windows.Controls.TextBlock
|
||||
{
|
||||
Text = swatch.Name,
|
||||
Foreground = new System.Windows.Media.SolidColorBrush(
|
||||
System.Windows.Media.Color.FromRgb(0xa0, 0xa0, 0xb0)),
|
||||
Margin = new System.Windows.Thickness(10, 0, 0, 0)
|
||||
});
|
||||
var item = new System.Windows.Controls.MenuItem
|
||||
{
|
||||
Header = header,
|
||||
ToolTip = $"{swatch.Name} ({swatch.Hex})"
|
||||
};
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(swatch.Hex));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = swatch.Hex;
|
||||
item.Click += (_, _) => src.BorderColor = color;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
fe.ContextMenu = menu;
|
||||
}
|
||||
if (sender is FrameworkElement fe2)
|
||||
fe2.ContextMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
+39
-11
@@ -1,10 +1,11 @@
|
||||
# HANDOFF — 2026-10-21 (the creator's three properties-panel asks shipped; the vv webcam Units 1+2 state below)
|
||||
# HANDOFF — 2026-10-21 (the creator's properties-panel asks shipped, incl. TASK 63; the vv webcam Units 1+2 state below)
|
||||
|
||||
## At a glance
|
||||
- **Branch** main, tracking `origin/main`. **origin/main is still `3a77662`, so five commits are
|
||||
- **Branch** main, tracking `origin/main`. **origin/main is still `3a77662`, so six commits are
|
||||
waiting to be pushed** (TASK 58 the theme sweep, TASK 59 the properties hierarchy, TASK 60 the
|
||||
colour names, a docs commit, and TASK 61+62 the geometry field widths and the colour name beside
|
||||
the hex — the creator's properties-panel asks, done in one batch). **Not pushed yet:** the push
|
||||
colour names, a docs commit, TASK 61+62 the geometry field widths and the colour name beside
|
||||
the hex, and TASK 63 the border-colour picker as the app's own drop-down — the creator's
|
||||
properties-panel asks, done in one batch). **Not pushed yet:** the push
|
||||
happens at a sub-milestone/milestone or at the creator's word ("push it") — the creator has not
|
||||
said so for this batch.
|
||||
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
|
||||
@@ -53,6 +54,24 @@
|
||||
(`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed
|
||||
width so the name stays next to the hex. The field stays editable (a custom hex can
|
||||
still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
|
||||
- **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21). The creator:
|
||||
*"why does the drop down under the web cam properties not show the same style for the border
|
||||
color drop down list box?"* and *"we established look-and-feel standards. And yet you keep
|
||||
ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for
|
||||
control **types** (`ComboBox`/`CheckBox`/`TextBox`) without a theme style - and the border picker
|
||||
was NONE of those: it was a `...` `Button` whose `Click` handler built a `ContextMenu` in code.
|
||||
It was never in the sweep's scope, and `PropertiesStyleTests.cs` (a type-based guard) could not
|
||||
see it either. Now: the picker is a **`YtComboBox`** bound to the palette
|
||||
(`SelectedElement.BorderColorOptions` / `BorderColorEntry`), the **closed box shows the swatch +
|
||||
the colour's NAME**, the **rows show swatch + hex + name**, the hex field stays for a custom
|
||||
colour, the swatch is painted by the theme's `HexToBrush` converter, and the hand-built menu +
|
||||
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
|
||||
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
|
||||
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
|
||||
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). Guard:
|
||||
`ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real widget**). The TASK 60
|
||||
guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to
|
||||
the palette; no second list of colours anywhere).
|
||||
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
|
||||
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
|
||||
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
|
||||
@@ -188,6 +207,11 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
the full top area and the circle option is not offered. **PASSES.**
|
||||
|
||||
## Next step
|
||||
- **Creator check of TASK 63 (needs the creator's eyes)** — open Live → Layers → webcam →
|
||||
Properties → Border Color: the drop-down must now LOOK like the Display/Font drop-downs (same
|
||||
dark themed chrome, same chevron), the CLOSED box must show the colour's name beside the hex
|
||||
field ("Crimson"), and the open list must show swatch + hex + name. The hex field still accepts a
|
||||
typed colour (the drop-down then shows the swatch with no name — "no name" is the honest answer).
|
||||
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
|
||||
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
|
||||
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
|
||||
@@ -202,10 +226,13 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
`Controls/LeftPanel.xaml` (the `SelectedElement.IsWebSource` panel, ~lines 387-402) have the
|
||||
identical shape and the identical defect as the webcam's did — the Y/H fields stretch to fill the
|
||||
row. Same one-line-per-field fix as TASK 61, when the creator wants it.
|
||||
- **Follow-up spotted while doing TASK 60 (not fixed — out of scope for that change):** the border
|
||||
colour swatches in the drop-down are a plain `Border` with the colour as `Background` and **no
|
||||
border**, so the black (`#000000`) and dark swatches are nearly invisible against the dark menu
|
||||
(`#16213e`). A 1px `BorderBrush="#2a3a5e"` on the swatch would fix it.
|
||||
- **✅ FIXED in TASK 63:** the border-colour swatches now carry a 1px `BorderBrush="#2a3a5e"`, so
|
||||
the black (`#000000`) and dark swatches are visible against the dark menu (`#16213e`).
|
||||
- **Follow-up spotted while doing TASK 63 (not fixed — out of scope for that change):** the theme has
|
||||
**no implicit `Button` style**, so every plain `<Button>` in the app still renders with the light
|
||||
WPF system template (`Controls/LeftPanel.xaml` ×7, `Controls/CameraPickerDialog.xaml` ×2). The
|
||||
border picker's `...` button was one of them and is gone; the rest are still there. A theme-level
|
||||
implicit `Button` style would fix them all at once — that is a creator-queued task, not a silent edit.
|
||||
- **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG
|
||||
put `www.gog.com` behind a maintenance page (503) and their Fastly services for `auth.gog.com` /
|
||||
`api.gog.com` / `remote-config.gog.com` return `500 "Fastly error: unknown domain"`; the page itself
|
||||
@@ -230,9 +257,10 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
|
||||
- TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
|
||||
|
||||
## Tests
|
||||
- Last full run: **435/435 passed, 0 warnings** (2026-10-21, this session — after TASK 61's
|
||||
`GeometryFieldWidthTests` and TASK 62's `BorderColorNameTests`; the runs before were 433/433 after
|
||||
TASK 60, 432/432 after TASK 59 and 431/431 after TASK 58). The one failure seen earlier in the
|
||||
- Last full run: **435/435 passed, 0 warnings** (2026-10-21, this session — after TASK 63's
|
||||
`BorderColorPickerTests`, which replaced TASK 62's `BorderColorNameTests`; the run before was also
|
||||
435/435 after TASK 61+62, and the runs before that were 433/433 after TASK 60, 432/432 after
|
||||
TASK 59 and 431/431 after TASK 58). The one failure seen earlier in the
|
||||
session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
|
||||
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
|
||||
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Gives a ComboBox's CLOSED box a different template from its drop-down
|
||||
/// rows (TASK 63).
|
||||
///
|
||||
/// <para>WPF asks an <see cref="ItemTemplateSelector"/> with the ContentPresenter's
|
||||
/// templated parent: the <see cref="ComboBox"/> itself when it renders the closed
|
||||
/// selection box, the <see cref="ComboBoxItem"/> when it renders a row. That is the
|
||||
/// only way to tell the two apart — <c>ComboBox.SelectionBoxItemTemplate</c> is
|
||||
/// read-only, and WPF copies <c>ItemTemplate</c> into it, so a ComboBox otherwise
|
||||
/// cannot show one thing when shut and another when open.</para>
|
||||
///
|
||||
/// <para>Verified against WPF on .NET 8 (2026-10-21): with the rows' presenter the
|
||||
/// templated parent is the ComboBoxItem, with the closed box's it is the ComboBox.</para>
|
||||
/// </summary>
|
||||
public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
|
||||
{
|
||||
/// <summary>The template for the closed box — what the creator reads while the
|
||||
/// drop-down is shut.</summary>
|
||||
public DataTemplate? ClosedTemplate { get; set; }
|
||||
|
||||
/// <summary>The template for the drop-down rows.</summary>
|
||||
public DataTemplate? RowTemplate { get; set; }
|
||||
|
||||
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
|
||||
=> container is FrameworkElement { TemplatedParent: ComboBox }
|
||||
? ClosedTemplate
|
||||
: RowTemplate;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
|
||||
/// swatch straight from the palette (TASK 63).
|
||||
///
|
||||
/// <para>Without it the UI would have to hand-build a brush in code — the hand-painted
|
||||
/// approach the theme forbids. A colour is data; the theme paints it.</para></summary>
|
||||
public class HexToBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string hex || string.IsNullOrWhiteSpace(hex))
|
||||
return Brushes.Transparent;
|
||||
try
|
||||
{
|
||||
var colour = (Color)ColorConverter.ConvertFromString(hex.Trim());
|
||||
return new SolidColorBrush(colour);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("A brush is not a hex value.");
|
||||
}
|
||||
@@ -16,6 +16,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
||||
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see ai.md "Render budget") |
|
||||
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see ai.md "Frame cadence" |
|
||||
| `HexToBrushConverter.cs` | A "#RRGGBB" hex string → `SolidColorBrush` (transparent on bad input), so a template can paint a colour swatch straight from the palette. Registered in the theme as `HexToBrush` — the theme paints the colour, never the UI code (TASK 63) |
|
||||
| `ClosedBoxTemplateSelector.cs` | An `ItemTemplateSelector` that gives a ComboBox's CLOSED box a different template from its drop-down rows: WPF calls it with the `ContentPresenter`'s templated parent — the `ComboBox` for the closed box, the `ComboBoxItem` for a row. The only way to tell them apart (`SelectionBoxItemTemplate` is read-only and mirrors `ItemTemplate`) — TASK 63 |
|
||||
|
||||
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
|
||||
class backs; [`Models/index.md`](../Models/index.md) and
|
||||
|
||||
@@ -4,7 +4,12 @@ namespace ytLive.Models;
|
||||
/// name shown next to it (TASK 60).</summary>
|
||||
/// <param name="Hex">The hex value, e.g. <c>#e94560</c>.</param>
|
||||
/// <param name="Name">The name the creator reads, e.g. <c>Crimson</c>.</param>
|
||||
public readonly record struct NamedColor(string Hex, string Name);
|
||||
public readonly record struct NamedColor(string Hex, string Name)
|
||||
{
|
||||
/// <summary>"Crimson (#e94560)" — the tooltip the border drop-down shows.</summary>
|
||||
public override string ToString()
|
||||
=> string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
|
||||
}
|
||||
|
||||
/// <summary>The colours the webcam's border-colour drop-down offers — the SINGLE
|
||||
/// source of truth for the list and for the names.
|
||||
@@ -38,13 +43,30 @@ public static class BorderColorPalette
|
||||
/// <summary>The colour's name, or an **empty string** when the palette has no name
|
||||
/// for that hex (a custom hex the creator typed) — the caller decides what to show
|
||||
/// then, and the border field shows the hex alone.</summary>
|
||||
public static string NameOf(string hex)
|
||||
public static string NameOf(string hex) => EntryFor(hex).Name;
|
||||
|
||||
/// <summary>The palette entry for a hex value — the palette's OWN entry (so a
|
||||
/// drop-down bound to it matches by value), or a nameless entry for a custom hex
|
||||
/// the creator typed, so the drop-down always has a selection (TASK 63).</summary>
|
||||
public static NamedColor EntryFor(string hex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex)) return string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(hex)) return new NamedColor(string.Empty, string.Empty);
|
||||
var trimmed = hex.Trim();
|
||||
foreach (var colour in All)
|
||||
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
return colour.Name;
|
||||
return string.Empty;
|
||||
return colour;
|
||||
return new NamedColor(trimmed, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>The colours a border drop-down offers for a given hex — the palette, plus
|
||||
/// the custom colour the creator typed when it is not one of the palette's, so the
|
||||
/// drop-down always has a selection (TASK 63).</summary>
|
||||
public static IReadOnlyList<NamedColor> OptionsFor(string hex)
|
||||
{
|
||||
var entry = EntryFor(hex);
|
||||
var options = new List<NamedColor>(All);
|
||||
if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
|
||||
options.Add(entry);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -264,15 +264,25 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
{
|
||||
Raise(nameof(HasBorder));
|
||||
Raise(nameof(BorderBrush));
|
||||
Raise(nameof(BorderColorName));
|
||||
Raise(nameof(BorderColorEntry));
|
||||
Raise(nameof(BorderColorOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The palette's name for the current border colour — "Crimson" for
|
||||
/// #e94560, and EMPTY for a custom hex the creator typed (the field shows the hex
|
||||
/// alone then). Shown next to the hex field (TASK 62).</summary>
|
||||
public string BorderColorName => BorderColorPalette.NameOf(BorderColor);
|
||||
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
|
||||
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
|
||||
/// typed. Setting it writes the hex back (TASK 63).</summary>
|
||||
public NamedColor BorderColorEntry
|
||||
{
|
||||
get => BorderColorPalette.EntryFor(BorderColor);
|
||||
set => BorderColor = value.Hex;
|
||||
}
|
||||
|
||||
/// <summary>The colours the border drop-down offers — the palette, plus the custom
|
||||
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
|
||||
public IReadOnlyList<NamedColor> BorderColorOptions
|
||||
=> BorderColorPalette.OptionsFor(BorderColor);
|
||||
|
||||
private double _borderOpacity = 1.0;
|
||||
public double BorderOpacity
|
||||
|
||||
+2
-2
@@ -8,12 +8,12 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 62:** `BorderColorName` — the palette's name for the current border colour (empty for a custom hex), raised when `BorderColor` changes |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
|
||||
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the picker builds its menu from it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson` |
|
||||
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
|
||||
@@ -2213,3 +2213,64 @@ widths should be fixed to accomodate the max number of possible digits."* The ro
|
||||
5. **A label beside a field needs the field to be FIXED too.** The same trap in a different
|
||||
costume: a field that fills its row pushes whatever sits after it to the far edge, so the
|
||||
label drifts away from the value it labels (TASK 62 — the colour name beside the hex field).
|
||||
|
||||
## 2026-10-21 — A STYLE SWEEP THAT LOOKS AT CONTROL TYPES CANNOT SEE A HAND-ROLLED PICKER (RECIPE — TASK 63)
|
||||
|
||||
The creator: *"one of the first things we did was define branding. We established look-and-feel
|
||||
standards. And yet you keep ignoring these and I'm wondering why"* — about the webcam's
|
||||
border-colour drop-down.
|
||||
|
||||
**The trap:** the TASK 58 sweep had already walked the whole app for un-themed controls, and the
|
||||
border picker STILL was not themed. The reason is a one-line blind spot in the sweep itself:
|
||||
it asked *"does this `ComboBox`/`CheckBox`/`TextBox` carry a theme style?"* — and the border
|
||||
picker was **none of those types**. It was a `Button` whose `Click` handler built a
|
||||
`ContextMenu` of swatches in code. The sweep's guard (`PropertiesStyleTests.cs`) asserts control
|
||||
**types**, so it could not see it either. A picker that LOOKS like a drop-down is invisible to a
|
||||
type-based audit.
|
||||
|
||||
**The recipe:**
|
||||
1. When auditing for the design system, search for **what a control DOES**, not what it IS:
|
||||
grep for `Click=` handlers that open a `ContextMenu`/`Menu`/`Popup`, and for `new
|
||||
ContextMenu`/`new MenuItem` in code-behind. A menu built in code is a control that never
|
||||
wears the theme.
|
||||
2. The guard must assert the **design system**, not the control type: for every control that
|
||||
presents a CHOICE to the creator, assert it is the app's widget (`Style="{StaticResource
|
||||
YtComboBox}"` etc.). "It is a ComboBox" is not the requirement; "it is the app's ComboBox" is.
|
||||
3. **A hand-painted brush in code is the same smell as a hand-painted `Background`** — the theme
|
||||
owns the colour. A colour is DATA: bind it and let a converter paint it
|
||||
(`Helpers/HexToBrushConverter.cs`).
|
||||
|
||||
## 2026-10-21 — A COMBOBOX'S CLOSED BOX AND ITS ROWS CAN ONLY DIFFER THROUGH A TEMPLATE SELECTOR (RECIPE — TASK 63)
|
||||
|
||||
WPF copies `ComboBox.ItemTemplate` into `SelectionBoxItemTemplate`, and that property is
|
||||
**read-only in C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned
|
||||
to -- it is read only"). So one `ItemTemplate` renders the SAME content in the closed box and in
|
||||
every row — you cannot show "Crimson" when shut and "#e94560 Crimson" when open.
|
||||
|
||||
**The one mechanism that works — an `ItemTemplateSelector`.** WPF calls it with the
|
||||
`ContentPresenter`'s **templated parent**:
|
||||
|
||||
| What is rendering | The container's `TemplatedParent` |
|
||||
|---|---|
|
||||
| the CLOSED selection box | the **`ComboBox`** |
|
||||
| a drop-down ROW | the **`ComboBoxItem`** |
|
||||
|
||||
```csharp
|
||||
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
|
||||
=> container is FrameworkElement { TemplatedParent: ComboBox } ? ClosedTemplate : RowTemplate;
|
||||
```
|
||||
|
||||
**What does NOT work:** setting `ComboBoxItem.ContentTemplate` through an `ItemContainerStyle`.
|
||||
WPF overwrites the container's `ContentTemplate` with the ComboBox's `ItemTemplate` (null) when
|
||||
it prepares the container, so the style's value is wiped.
|
||||
|
||||
**How I found it (the probe recipe):** a throwaway xunit test in the test project (STA via the
|
||||
`RealApp` collection host), a `ComboBox` in a shown `Window`, an `ItemTemplateSelector` that
|
||||
logs `container.GetType().Name` + the parent's type, and a `DispatcherPriority.Loaded` pump so
|
||||
the popup materialises its rows. The log `item=a,parent=ComboBox | item=a,parent=ComboBoxItem`
|
||||
settled the design in one run. **Probe, then code** — this is not in any WPF book.
|
||||
|
||||
**Bonus measurement (the theme's popup):** the `YtComboBox` popup carries no width binding, so it
|
||||
sizes to its **content**, not to the ComboBox — measured 142.7px of popup for a 100px ComboBox
|
||||
whose rows read "swatch + hex + name". A long row is never clipped, and the popup may be wider
|
||||
than the closed box. Measure with `ActualWidth` in a throwaway test; do not guess widths.
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
| 60 | **The border-colour drop-down names every colour** | ✅ Done — 2026-10-21 (the creator's "the #hex values are good, but add a corresponding color name/label next to the value" — the 14 colours moved out of the click handler into `Models/BorderColorPalette.cs`, and each row now reads "#e94560 Crimson") | [`TASKS/task-60-border-color-names.md`](TASKS/task-60-border-color-names.md) |
|
||||
| 61 | **The geometry fields share one fixed width** | ✅ Done — 2026-10-21 (the creator's "the Y and H boxes should be the same width as the X and W boxes and those widths should be fixed to accomodate the max number of possible digits" — the Y/H fields used to stretch to fill the row; all four are now 44px, wide enough for 1920 plus a sign) | [`TASKS/task-61-geometry-field-widths.md`](TASKS/task-61-geometry-field-widths.md) |
|
||||
| 62 | **The border-colour field shows the colour's name next to the hex** | ✅ Done — 2026-10-21 (the creator: "the border color drop down box content should also include the name of the color next to the hex value as they in the drop-down list box" — the field keeps the hex editable and the palette's name sits right beside it, empty for a custom hex) | [`TASKS/task-62-border-colour-name-in-the-field.md`](TASKS/task-62-border-colour-name-in-the-field.md) |
|
||||
| 63 | **The border-colour picker is the app's own drop-down** | ✅ Done — 2026-10-21 (the creator's "why does the drop down under the web cam properties not show the same style for the border color drop down list box?" + "we established look-and-feel standards. And yet you keep ignoring these" — the hand-rolled `...` button and its code-built menu are gone; the border colour is now a `YtComboBox` like every other picker, the closed box shows the swatch + the colour's name, the rows show swatch + hex + name, and the hex field stays for a custom colour) | [`TASKS/task-63-border-colour-app-dropdown.md`](TASKS/task-63-border-colour-app-dropdown.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# TASK 63 — The border-colour picker is the app's own drop-down
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**:
|
||||
|
||||
> why does the drop down under the web cam properties not show the same style for the border
|
||||
> color drop down list box?
|
||||
|
||||
and, with the reason:
|
||||
|
||||
> one of the first things we did was define branding. We established look-and-feel standards.
|
||||
> And yet you keep ignoring these and I'm wondering why
|
||||
|
||||
So the border colour is picked with the **app's own drop-down** — the `YtComboBox` every other
|
||||
picker wears — instead of a `...` button that built a `ContextMenu` in code.
|
||||
|
||||
## Why it was missed (the root cause, recorded so it cannot recur)
|
||||
The TASK 58 sweep looked for controls **by type** (`ComboBox` / `CheckBox` / `TextBox`) that
|
||||
carried no theme style. The border picker was **none of those** — it was a `Button` whose
|
||||
`Click` handler built a `ContextMenu` of swatches in code. So it was never in the sweep's
|
||||
scope, and `ytLive.Tests/PropertiesStyleTests.cs` could not see it either: the guard asserts
|
||||
control **types**, not the design system. A style sweep must ask *"is this the app's widget?"*,
|
||||
not *"is this control type themed?"*.
|
||||
|
||||
## How it is done
|
||||
- **The picker** — a `ComboBox` with `Style="{StaticResource YtComboBox}"`, bound to
|
||||
`SelectedElement.BorderColorOptions` (the palette) and `SelectedItem` to
|
||||
`SelectedElement.BorderColorEntry` (TwoWay). `SelectedItem`, **not** `SelectedValue`: WPF
|
||||
matches `SelectedValue` ordinally (case-sensitively) and the palette mixes `#FFFFFF` and
|
||||
`#e94560`; the model matches by VALUE, so the hex the creator typed in any case still lands
|
||||
on the right row.
|
||||
- **The closed box shows the swatch + the colour's NAME; the rows show swatch + hex + name**
|
||||
(the rows the creator approved in TASK 60). A ComboBox cannot do that with one
|
||||
`ItemTemplate` — WPF copies it into the read-only `SelectionBoxItemTemplate` — so
|
||||
`Helpers/ClosedBoxTemplateSelector.cs` picks the template by the `ContentPresenter`'s
|
||||
templated parent (the `ComboBox` for the closed box, the `ComboBoxItem` for a row).
|
||||
- **The hex field stays** (60px, editable): a colour outside the palette can still be typed,
|
||||
and the drop-down then carries that hex as a **nameless** entry ("no name" is the honest
|
||||
answer). The palette's name for the current colour moved out of a `TextBlock` (TASK 62's
|
||||
`BorderColorName`) and into the drop-down's closed box.
|
||||
- **The swatch is painted by the theme**, not by code: `Helpers/HexToBrushConverter.cs`
|
||||
(registered in `Themes/Controls.xaml` as `HexToBrush`) turns the hex into a brush, and the
|
||||
templates use the theme's border colour `#2a3a5e` so the black swatch is visible on the dark
|
||||
popup. The hand-built menu, its hand-painted `SolidColorBrush`es and
|
||||
`WebcamBorderColorPicker_Click` are **gone**.
|
||||
|
||||
## The model
|
||||
- `SceneElement.BorderColorName` → **`BorderColorEntry`** (the palette entry for the current
|
||||
hex, with its name; setting it writes the hex back) and **`BorderColorOptions`** (the
|
||||
palette plus the custom entry). Both are raised when `BorderColor` changes.
|
||||
- `BorderColorPalette.EntryFor(hex)` (case-insensitive; a nameless entry for a colour outside
|
||||
the palette) and `OptionsFor(hex)` (the palette + that entry). `NameOf` now delegates to
|
||||
`EntryFor`.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/BorderColorPickerTests.cs` — the ONE test for this change, in three parts:
|
||||
the model (the palette entry, the custom hex, the raises), the panel (the picker IS a
|
||||
`YtComboBox` over the palette, the hex field survives, the hand-rolled menu is gone from both
|
||||
the XAML and the code-behind) and the **real widget** — with the app's theme style and the
|
||||
panel's real templates, the closed box shows the name and the rows show the hex AND the name.
|
||||
`ytLive.Tests/BorderColorPaletteTests.cs` (TASK 60) was updated: its "the picker builds its
|
||||
menu from the palette" step now guards the new wiring (the drop-down is bound to the palette,
|
||||
and neither the panel nor the code-behind carries a second list of colours).
|
||||
|
||||
## Follow-ups (not done — out of scope)
|
||||
- The theme has **no implicit `Button` style**, so every plain `<Button>` in the app still
|
||||
renders with the light WPF system template (`LeftPanel.xaml` ×7, `CameraPickerDialog.xaml`
|
||||
×2). The border picker's `...` button was one of them; the rest are still there.
|
||||
- The palette's hexes keep their mixed casing (`#FFFFFF` vs `#e94560`).
|
||||
@@ -760,5 +760,6 @@
|
||||
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
|
||||
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
|
||||
<Helpers:AllTrueToVisibilityConverter x:Key="AllTrueToVis"/>
|
||||
<Helpers:HexToBrushConverter x:Key="HexToBrush"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -26,6 +26,20 @@ Gotchas learned the hard way:
|
||||
2026-10-21). **Rule: every control carries a theme style** — `YtComboBox`,
|
||||
`YtCheckBox`, `YtTextBox`/`YtTextBoxCompact` — and paints no colour of its
|
||||
own. `ytLive.Tests/PropertiesStyleTests.cs` fails if that drifts back.
|
||||
⚠️ **But that guard sees only those control TYPES** — a picker built as a
|
||||
`Button` + a code-built `ContextMenu` is invisible to it (the border-colour
|
||||
picker was exactly that, TASK 63). A picker that LOOKS like a drop-down must
|
||||
BE the app's drop-down.
|
||||
- **The `YtComboBox` popup sizes to its CONTENT, not to the ComboBox** (measured
|
||||
2026-10-21: the border-colour popup came out 142.7px wide against a 100px
|
||||
ComboBox) — a long row is never clipped, and the popup may be wider than the
|
||||
closed box. Measure, don't guess.
|
||||
- **The closed box and the rows of a ComboBox can only differ through an
|
||||
`ItemTemplateSelector`** — WPF copies `ItemTemplate` into the read-only
|
||||
`SelectionBoxItemTemplate`, so one template renders in both places. The
|
||||
selector is told them apart by the `ContentPresenter`'s templated parent
|
||||
(`ComboBox` = closed box, `ComboBoxItem` = a row) — see
|
||||
`Helpers/ClosedBoxTemplateSelector.cs`.
|
||||
|
||||
Related: [`Helpers/index.md`](../Helpers/index.md) (custom controls the theme
|
||||
styles), the XAML files at repo root that consume these styles.
|
||||
|
||||
@@ -276,7 +276,7 @@ C# / WPF (.NET 8) following MVVM:
|
||||
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
|
||||
- **Element z-order is a unified space, not per-table (2026-09-20):** `Source` and `WebcamSceneConfig` live in different tables, and a webcam can be interleaved between sources in a scene. Both tables' `SortOrder` is stamped from the SAME counter — the element's index within `scene.Elements` — and `Load` merges the two tables' rows per scene by that shared z (tie-break sources-first, so legacy per-table rows read back stacked the way they were saved). Never re-introduce per-type sort counters + "append all Sources then all configs", or a webcam slotted mid-stack reverts on reload (creator-reproduced bug).
|
||||
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
|
||||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back
|
||||
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary). **Every control in the app carries a theme style (TASK 58, 2026-10-21):** an unstyled `ComboBox`/`CheckBox` falls back to the **light WPF system template** (grey border, system arrow, white popup list) and sticks out of the dark panels — the properties panel's drop-downs had been hand-painted with a `Background`/`Foreground` instead of a style (the chat Font drop-down was a copy of that), and every check box was default. The rule now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox`, the dense properties fields → `YtTextBoxCompact` (`BasedOn YtTextBox`: 11px, padding 4,2) — **no hand-painted `Background`/`BorderBrush` on a themed control**. `ytLive.Tests/PropertiesStyleTests.cs` is the guard: it fails if an unstyled drop-down, check box or properties field comes back — **but that guard sees only those three control types**, so a picker built as something else (a `Button` + a code-built `ContextMenu`) slipped through it and the creator had to ask twice (TASK 63)
|
||||
- **The properties panel is a HIERARCHY (TASK 59, 2026-10-21):** a **title owns its content, and the content is indented one level (10px) under it** — the creator's ruling: *"the 'drag to move' help text needs to be indented under 'Mirror Rect' to show parentage … same with Geometry, Border Color, Border Thickness … Remove should be indented under Background as should Blur … Strength should be indented under Blur."* So the webcam's block reads `Shape` (the mirror switch + the shape choice) → `Geometry` → `Border Color` → `Border Thickness` → `Background` (Remove, Blur, and Strength one level deeper under Blur), each title SemiBold at 11px `#a0a0b0` like the existing ones. **The shape is a CHOICE, not a toggle button:** `IsRectShape`/`IsRoundShape` on `SceneElement` are two settable bools and the panel shows them as two `SegmentToggle` options (Rect | Circle) with **Rect selected by default** (an element that never picked a shape is a rect) — the old single button showed the *next* action ("Round"), which is why the creator could not tell what the camera was. The mutual exclusion lives in the MODEL (each setter flips through `ToggleClipShape()` and both props are re-raised), **not** in a WPF `RadioButton` group — a RadioButton group unchecks its siblings by setting `IsChecked` in code, which silently kills the binding (stackoverflow 7376373). Each setter always re-raises, so a refused write (the vertical Chat layout is rect-only) makes the UI snap back. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`
|
||||
- **One list for the offered colours (TASK 60):** the border-colour drop-down builds its menu from
|
||||
`Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` + `NameOf(hex)` — **empty** for a colour
|
||||
@@ -286,6 +286,32 @@ C# / WPF (.NET 8) following MVVM:
|
||||
testable catalogue, with the UI as a consumer. **TASK 62:** the field that shows the *current* colour
|
||||
shows the name beside it too (`SceneElement.BorderColorName`), and the hex field keeps a fixed width
|
||||
so the name stays next to the hex instead of drifting to the row's far edge
|
||||
- **A picker IS the app's drop-down (TASK 63, 2026-10-21):** the border colour is now a
|
||||
**`YtComboBox`** — the same widget the Display and Font pickers wear — not a `...` button that
|
||||
built a `ContextMenu` in code. The creator's complaint (*"one of the first things we did was define
|
||||
branding. We established look-and-feel standards. And yet you keep ignoring these"*) had a precise
|
||||
cause: the TASK 58 sweep looked for `ComboBox`/`CheckBox`/`TextBox` **types**, and the border picker
|
||||
was neither — it was a hand-rolled `Button` + code-built menu, so it was never in scope, and the
|
||||
guard could not see it. **Lesson: a style sweep must ask "is this the app's widget?", not "is this
|
||||
control type themed?"** — anything hand-rolled that *looks* like a picker (a button that opens a
|
||||
menu) is invisible to a type-based guard. The hex field stays (a custom colour can still be typed);
|
||||
the drop-down carries the swatch and the colour's NAME, the rows carry swatch + hex + name.
|
||||
Guard: `ytLive.Tests/BorderColorPickerTests.cs`
|
||||
- **A ComboBox's CLOSED box and its ROWS can differ — only through a template selector (TASK 63):**
|
||||
WPF copies `ItemTemplate` into `SelectionBoxItemTemplate`, and that property is **read-only in
|
||||
C#** ("Property or indexer 'ComboBox.SelectionBoxItemTemplate' cannot be assigned"), so a plain
|
||||
`ItemTemplate` renders the SAME content in the closed box and in every row. The one mechanism that
|
||||
tells the two apart is an `ItemTemplateSelector`: WPF calls it with the `ContentPresenter`'s
|
||||
**templated parent** — the **`ComboBox`** when the closed box renders, the **`ComboBoxItem`** when a
|
||||
row renders. `Helpers/ClosedBoxTemplateSelector.cs` does exactly that (`ClosedTemplate` for the
|
||||
ComboBox, `RowTemplate` for the ComboBoxItem). A `ComboBoxItem.ContentTemplate` set through an
|
||||
`ItemContainerStyle` does **not** work: WPF overwrites the container's `ContentTemplate` with the
|
||||
ComboBox's `ItemTemplate` (null) — so use the selector.
|
||||
- **The theme's drop-down popup sizes to its CONTENT, not to the ComboBox (measured 2026-10-21):**
|
||||
the `YtComboBox` popup carries no width binding, so the pop-up is as wide as its widest row
|
||||
(measured 142.7px for the border-colour rows against a 100px ComboBox) — a row is never clipped,
|
||||
and the popup may be wider than the closed box. Measure (`RenderTargetBitmap`/`ActualWidth` in a
|
||||
throwaway test), don't guess.
|
||||
- **A field in a dense panel carries a FIXED width (TASK 61):** a WPF element sizes itself to its
|
||||
content, and in a `DockPanel` the **last child fills the row** — so a row of same-kind fields looks
|
||||
broken unless every field has an explicit `Width` AND the row sets `LastChildFill="False"`. The
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 62 — the creator (2026-10-21): *"the border color drop down box content
|
||||
/// should also include the name of the color next to the hex value as they in the drop-down
|
||||
/// list box."*
|
||||
///
|
||||
/// <para>One test: the model carries the palette's name for the current border colour
|
||||
/// (and nothing for a custom hex the creator typed), the name raises when the colour
|
||||
/// changes, and the panel shows it right next to the hex field — which keeps a fixed
|
||||
/// width so the name stays beside the hex instead of drifting to the far edge.</para></summary>
|
||||
public class BorderColorNameTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_border_colour_field_shows_the_colour_name_next_to_the_hex()
|
||||
{
|
||||
// ── The model: the name of the current colour, and nothing for a custom hex ──
|
||||
var element = new WebcamSceneConfig { Name = "Webcam" };
|
||||
var raised = new List<string?>();
|
||||
element.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
|
||||
|
||||
element.BorderColor = "#e94560";
|
||||
Assert.Equal("Crimson", element.BorderColorName);
|
||||
Assert.Contains("BorderColorName", raised);
|
||||
|
||||
raised.Clear();
|
||||
element.BorderColor = "#123456";
|
||||
Assert.Equal("", element.BorderColorName); // a custom hex has no name
|
||||
Assert.Contains("BorderColorName", raised);
|
||||
|
||||
// ── The panel: the name sits right next to the hex field ──────────────────
|
||||
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
var title = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == "Border Color");
|
||||
var row = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
|
||||
|
||||
var field = row.Descendants().Single(e => e.Name.LocalName == "TextBox");
|
||||
var name = row.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& ((string?)e.Attribute("Text") ?? "").Contains("BorderColorName"));
|
||||
|
||||
// The hex field has a fixed width, so the name stays beside the hex.
|
||||
Assert.False(string.IsNullOrWhiteSpace((string?)field.Attribute("Width")),
|
||||
"the hex field has no fixed width — the name would drift to the far edge");
|
||||
|
||||
// The name is in the same horizontal row as the field, after it.
|
||||
Assert.Equal(field.Parent, name.Parent);
|
||||
var order = field.Parent!.Elements().ToList();
|
||||
Assert.True(order.IndexOf(field) < order.IndexOf(name),
|
||||
"the colour name comes before the hex field");
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,19 @@ public class BorderColorPaletteTests
|
||||
Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
|
||||
Assert.Equal("", BorderColorPalette.NameOf("#123456"));
|
||||
|
||||
// 4. The picker builds its menu from the catalogue — there is no second list.
|
||||
// 4. The palette is still the single source of truth — the drop-down is bound to
|
||||
// it (TASK 63 replaced the code-built menu with the app's YtComboBox), and
|
||||
// neither the panel nor the code-behind carries a second list of colours.
|
||||
var panel = SourceAssert.Text("Controls", "LeftPanel.xaml");
|
||||
Assert.True(panel.Contains("SelectedElement.BorderColorOptions"),
|
||||
"the border drop-down is not bound to the palette");
|
||||
var model = SourceAssert.Text("Models", "SceneElement.cs");
|
||||
Assert.True(model.Contains("BorderColorPalette.OptionsFor"),
|
||||
"the border drop-down's options do not come from the palette");
|
||||
var source = SourceAssert.Text("Controls", "LeftPanel.xaml.cs");
|
||||
Assert.True(source.Contains("BorderColorPalette"),
|
||||
"the border picker does not build its menu from the palette");
|
||||
Assert.False(source.Contains("\"#FFFFFF\""),
|
||||
Assert.False(source.Contains("\"#FFFFFF\"", StringComparison.Ordinal),
|
||||
"the border picker still carries its own list of colours");
|
||||
Assert.False(panel.Contains("\"#FFFFFF\"", StringComparison.Ordinal),
|
||||
"the border drop-down still carries its own list of colours");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 63 — the creator (2026-10-21): *"why does the drop down under the web cam
|
||||
/// properties not show the same style for the border color drop down list box?"* — and the
|
||||
/// reason behind it: *"one of the first things we did was define branding. We established
|
||||
/// look-and-feel standards. And yet you keep ignoring these and I'm wondering why."*
|
||||
///
|
||||
/// <para>The border colour was a hand-rolled <c>...</c> button that built a
|
||||
/// <c>ContextMenu</c> in code — never a ComboBox, so the TASK 58 sweep (which only covered
|
||||
/// ComboBox/CheckBox/TextBox) never reached it. This guard holds the fix in place, in three
|
||||
/// parts:</para>
|
||||
///
|
||||
/// <para><b>The model</b> — the border colour is a palette entry the drop-down can select by
|
||||
/// VALUE (carrying the colour's name), and a custom hex the creator typed is still offered.</para>
|
||||
///
|
||||
/// <para><b>The panel</b> — the picker IS the app's own <c>YtComboBox</c>, the widget every
|
||||
/// other drop-down wears, and the hand-rolled menu is gone from both the XAML and the
|
||||
/// code-behind. The hex field stays, so a colour outside the palette can still be typed.</para>
|
||||
///
|
||||
/// <para><b>The widget</b> — with the app's real theme style and the panel's real templates,
|
||||
/// the CLOSED box shows the colour's name and the drop-down ROWS show the hex AND the name.
|
||||
/// (WPF copies <c>ItemTemplate</c> into the read-only <c>SelectionBoxItemTemplate</c>, so the
|
||||
/// two can only differ through <see cref="ClosedBoxTemplateSelector"/>.)</para></summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class BorderColorPickerTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public BorderColorPickerTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void The_border_colour_picker_is_the_app_drop_down()
|
||||
{
|
||||
// ── 1. the model: the palette entry, and the custom hex ──────────────────
|
||||
var element = new WebcamSceneConfig { Name = "Webcam" };
|
||||
var raised = new List<string?>();
|
||||
element.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
|
||||
|
||||
element.BorderColor = "#e94560";
|
||||
Assert.Equal("Crimson", element.BorderColorEntry.Name);
|
||||
Assert.Equal("#e94560", element.BorderColorEntry.Hex);
|
||||
Assert.Contains("BorderColorEntry", raised);
|
||||
Assert.Contains("BorderColorOptions", raised);
|
||||
|
||||
element.BorderColorEntry = BorderColorPalette.All[0]; // picking a row
|
||||
Assert.Equal("#FFFFFF", element.BorderColor);
|
||||
|
||||
element.BorderColor = "#123456"; // a typed hex
|
||||
Assert.Equal(string.Empty, element.BorderColorEntry.Name); // no name to show
|
||||
Assert.Equal("#123456", element.BorderColorEntry.Hex);
|
||||
Assert.Contains(element.BorderColorOptions, o => o.Hex == "#123456");
|
||||
|
||||
element.BorderColor = string.Empty; // no colour yet
|
||||
Assert.Equal(BorderColorPalette.All.Count, element.BorderColorOptions.Count);
|
||||
Assert.DoesNotContain(element.BorderColorOptions, o => string.IsNullOrEmpty(o.Hex));
|
||||
|
||||
// ── 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()
|
||||
.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"));
|
||||
|
||||
var hexField = xaml.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBox")
|
||||
.Any(t => (string?)t.Attribute("Text")
|
||||
== "{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}");
|
||||
Assert.True(hexField, "the hex field is gone — a colour outside the palette can no longer be typed");
|
||||
|
||||
var panelSource = SourceAssert.Text("Controls", "LeftPanel.xaml");
|
||||
var codeBehind = SourceAssert.Text("Controls", "LeftPanel.xaml.cs");
|
||||
Assert.False(panelSource.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
|
||||
"the hand-rolled colour menu is back in the panel");
|
||||
Assert.False(codeBehind.Contains("WebcamBorderColorPicker_Click", StringComparison.Ordinal),
|
||||
"the hand-rolled colour menu is back in the code-behind");
|
||||
Assert.False(codeBehind.Contains("ColorConverter.ConvertFromString", StringComparison.Ordinal),
|
||||
"a colour is hand-painted in code — the theme paints it");
|
||||
|
||||
// ── 3. the widget: the closed box names the colour, the rows show hex + name ──
|
||||
element.BorderColor = "#FFFFFF";
|
||||
_app.Run(() =>
|
||||
{
|
||||
var panel = new LeftPanel();
|
||||
var selector = panel.Resources["BorderColorTemplates"] as ClosedBoxTemplateSelector;
|
||||
Assert.True(selector != null, "the panel carries no colour templates");
|
||||
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Style = (Style)Application.Current.Resources["YtComboBox"],
|
||||
ItemTemplateSelector = selector,
|
||||
ItemsSource = element.BorderColorOptions,
|
||||
SelectedItem = element.BorderColorEntry,
|
||||
};
|
||||
var window = new Window { Width = 420, Height = 240, Content = combo };
|
||||
window.Show();
|
||||
combo.UpdateLayout();
|
||||
|
||||
var closed = Texts(combo);
|
||||
Assert.True(closed.Contains("White", StringComparison.Ordinal),
|
||||
$"the closed box does not name the colour: '{closed}'");
|
||||
Assert.False(closed.Contains("#FFFFFF", StringComparison.Ordinal),
|
||||
$"the closed box repeats the hex the field already shows: '{closed}'");
|
||||
|
||||
combo.IsDropDownOpen = true;
|
||||
combo.UpdateLayout();
|
||||
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Loaded);
|
||||
|
||||
var popup = combo.Template.FindName("PART_Popup", combo) as Popup;
|
||||
var rows = popup?.Child == null ? string.Empty : Texts(popup.Child);
|
||||
Assert.True(rows.Contains("#FFFFFF", StringComparison.Ordinal),
|
||||
$"the drop-down rows do not show the hex: '{rows}'");
|
||||
Assert.True(rows.Contains("White", StringComparison.Ordinal),
|
||||
$"the drop-down rows do not show the colour's name: '{rows}'");
|
||||
|
||||
window.Close();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Every text in a visual tree, so a template's content can be read back.</summary>
|
||||
private static string Texts(DependencyObject root)
|
||||
{
|
||||
var texts = new List<string>();
|
||||
void Walk(DependencyObject node)
|
||||
{
|
||||
if (node is TextBlock block) texts.Add(block.Text);
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(node); i++)
|
||||
Walk(VisualTreeHelper.GetChild(node, i));
|
||||
}
|
||||
Walk(root);
|
||||
return string.Join("|", texts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user