Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be6a83dab | ||
|
|
987fddac24 | ||
|
|
ef52508b1e | ||
|
|
60b0218fa3 | ||
|
|
6271bf34c9 | ||
|
|
89f6aa3a67 | ||
|
|
2273d20160 |
+110
-66
@@ -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>
|
||||
@@ -155,12 +186,11 @@
|
||||
</TextBlock>
|
||||
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||
Padding="4,2" VerticalContentAlignment="Center"
|
||||
BorderThickness="0" FontSize="12"
|
||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Style TargetType="TextBox" BasedOn="{StaticResource YtTextBoxCompact}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
@@ -239,18 +269,31 @@
|
||||
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
|
||||
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Margin="8,0,0,0" Click="ShapeButton_Click"
|
||||
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- TASK 59: the shape is a CHOICE (Rect | Circle) under a
|
||||
"Shape" title, with Rect selected by default — the circle
|
||||
is not offered in the vertical Chat layout, so that option
|
||||
is not even shown there (CanToggleClipShape). -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Shape" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||
<TextBlock Text="Rect" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}"
|
||||
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<!-- The drag hint sits under the shape row — indented, so the
|
||||
parentage shows. -->
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="10,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||
@@ -260,43 +303,51 @@
|
||||
above) so the webcam's border/background section stays the
|
||||
one panel bound to SelectedElement.IsWebcam. -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||
for the largest geometry value (1920, plus a typed sign) and
|
||||
no wider, so Y/H no longer stretch to fill the row. -->
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
||||
ToolTip="Pick color"/>
|
||||
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
MaxLength="7" ToolTip="#RRGGBB"/>
|
||||
<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">
|
||||
<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" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="WebcamBorderAccept_Click">
|
||||
@@ -317,9 +368,11 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- TASK 51: background effects — two pills acting as radio buttons
|
||||
(one on turns the other off; both off = no effect). -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,6,0,6"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
(one on turns the other off; both off = no effect). TASK 59:
|
||||
the pills are indented under the Background title, and the
|
||||
strength row one level deeper, under Blur. -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
|
||||
@@ -327,7 +380,7 @@
|
||||
ToolTip="Remove the background behind you — the layers below show through"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
|
||||
@@ -335,7 +388,7 @@
|
||||
ToolTip="Blur the background behind you"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,6"
|
||||
<DockPanel Margin="20,0,0,6"
|
||||
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
|
||||
@@ -378,22 +431,18 @@
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel>
|
||||
@@ -410,8 +459,7 @@
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
ToolTip="https://example.com"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
@@ -434,7 +482,7 @@
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
|
||||
SelectedValuePath="Content"
|
||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Segoe UI"/>
|
||||
<ComboBoxItem Content="Arial"/>
|
||||
@@ -449,24 +497,21 @@
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Member Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Super Chat Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Max Messages -->
|
||||
@@ -482,8 +527,7 @@
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Background Opacity -->
|
||||
@@ -505,9 +549,9 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- Toggles -->
|
||||
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
|
||||
<CheckBox Content="Show timestamps" Style="{StaticResource YtCheckBox}" Margin="0,4,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
|
||||
<CheckBox Content="Show badges" Style="{StaticResource YtCheckBox}" Margin="0,0,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -531,8 +575,8 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
Margin="4,0,0,0" Padding="8,2"/>
|
||||
@@ -558,7 +602,7 @@
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag"
|
||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||
@@ -593,8 +637,8 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
|
||||
@@ -94,12 +94,6 @@ public partial class LeftPanel : UserControl
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||
@@ -247,36 +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();
|
||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
||||
foreach (var c in colors)
|
||||
{
|
||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = c;
|
||||
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)
|
||||
|
||||
@@ -542,8 +542,8 @@
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="0,0,0,10"
|
||||
ItemsSource="{Binding BroadcastForm.VisibilityOptions}"
|
||||
SelectedItem="{Binding BroadcastForm.Visibility}"/>
|
||||
<CheckBox Content="Made for kids" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,10"
|
||||
<CheckBox Content="Made for kids" Style="{StaticResource YtCheckBox}"
|
||||
FontSize="12" Margin="0,2,0,10"
|
||||
IsChecked="{Binding BroadcastForm.MadeForKids}"/>
|
||||
<TextBlock Text="SCHEDULED START" Style="{StaticResource YtLabel}" Margin="0,0,0,3"/>
|
||||
<TextBlock Text="{Binding BroadcastForm.ScheduledStartText}"
|
||||
@@ -655,8 +655,8 @@
|
||||
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.StandingDetailText}" Foreground="#888899" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<CheckBox Content="Two-Step Verification switched on" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,0"
|
||||
<CheckBox Content="Two-Step Verification switched on" Style="{StaticResource YtCheckBox}"
|
||||
FontSize="12" Margin="0,2,0,0"
|
||||
IsChecked="{Binding Ypp.TwoFactorEnabled}"/>
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
|
||||
Command="{Binding Ypp.OpenTwoStepCommand}" ToolTip="Open Google's 2-Step Verification page">
|
||||
@@ -667,8 +667,8 @@
|
||||
</Button.Template>
|
||||
<TextBlock Text="Set up on Google →" Foreground="#5aa7ff" FontSize="12" Margin="0,0,0,6"/>
|
||||
</Button>
|
||||
<CheckBox Content="AdSense account linked" Foreground="#e0e0e0" FontSize="12"
|
||||
Margin="0,2,0,0"
|
||||
<CheckBox Content="AdSense account linked" Style="{StaticResource YtCheckBox}"
|
||||
FontSize="12" Margin="0,2,0,0"
|
||||
IsChecked="{Binding Ypp.AdSenseLinked}"/>
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand" HorizontalAlignment="Left"
|
||||
Command="{Binding Ypp.OpenEarnCommand}" ToolTip="Open the YouTube Earn page">
|
||||
|
||||
+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 |
|
||||
| `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 |
|
||||
|
||||
+81
-9
@@ -1,10 +1,13 @@
|
||||
# HANDOFF — 2026-10-21 (vv webcam Units 1+2 shipped; the previous HA-lights session's state below)
|
||||
# HANDOFF — 2026-10-22 (the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below)
|
||||
|
||||
## At a glance
|
||||
- **Branch** main, tracking `origin/main`. **origin/main = `3a77662` = HEAD — up to date, nothing
|
||||
waiting.** The vv feature (Units 1+2) was **pushed at the feature close** (2026-10-21 — the
|
||||
creator approved the Unit 1 push after testing it, and the completed feature is a milestone).
|
||||
Push only at sub-milestones/milestones, at the creator's word.
|
||||
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
|
||||
(2026-10-22, as a sub-milestone): TASK 58 the theme sweep, TASK 59 the properties hierarchy,
|
||||
TASK 60 the 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. `origin/main` is now `987fdda`.
|
||||
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
|
||||
the Unit 1 push after testing it, and the completed feature is a milestone).
|
||||
- **The push path changed:** `origin` is now
|
||||
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
|
||||
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored
|
||||
@@ -18,6 +21,55 @@
|
||||
are `ytLive.Tests/VerticalWebcamLayoutTests.cs` (Unit 1) and
|
||||
`ytLive.Tests/VerticalChatCameraLayoutTests.cs` (Unit 2). **Only the creator's hands-on check is
|
||||
left** — see the vv section.
|
||||
- **TASK 58 — the properties panel now wears the theme** (shipped 2026-10-21). The creator's
|
||||
"why is the drop-down not in the same style as the others?" was a real defect: those drop-downs
|
||||
(and every check box, and every properties field) had been hand-painted with a `Background`/
|
||||
`Foreground` instead of a style, so WPF drew the **light system template** on top of the dark
|
||||
panel. Now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox` (new), properties fields →
|
||||
`YtTextBoxCompact` (new, BasedOn `YtTextBox`). Guard:
|
||||
`ytLive.Tests/PropertiesStyleTests.cs`.
|
||||
- **TASK 59 — the properties panel is a hierarchy** (shipped 2026-10-21). A title owns its
|
||||
content and the content is indented one level (10px): `Shape` (new title) over the
|
||||
Mirror switch and the shape choice, the drag hint indented under that row, Geometry /
|
||||
Border Color / Border Thickness as titles with their fields indented, Background's
|
||||
Remove + Blur indented, and Strength one level deeper under Blur. **The shape became a
|
||||
CHOICE**: `Rect` | `Circle` as two segment options with **Rect selected by default**
|
||||
(the old single button showed the *next* action, "Round"). The mutual exclusion lives
|
||||
in the model (`IsRectShape`/`IsRoundShape` on `SceneElement`) — a WPF `RadioButton`
|
||||
group would kill the bindings. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`.
|
||||
- **TASK 60 — the border-colour drop-down names every colour** (shipped 2026-10-21). The creator:
|
||||
*"the #hex values are good, but add a corresponding color name/label next to the value"* — each row
|
||||
now reads `#e94560 Crimson` (the name dimmed, the tooltip "Crimson (#e94560)"). The 14 colours
|
||||
moved out of the click handler into `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` +
|
||||
`NameOf(hex)`), so the list is testable and extensible. Guard:
|
||||
`ytLive.Tests/BorderColorPaletteTests.cs`.
|
||||
- **TASK 61 + 62 — the geometry fields and the border-colour field** (shipped 2026-10-21).
|
||||
- **61:** the X/Y/W/H fields share ONE fixed width (44px — the largest geometry value, 1920,
|
||||
plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill
|
||||
the row while X/W sized themselves to their text. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
(it MEASURES the widest value with WPF's `FormattedText` — measure, don't guess).
|
||||
- **62:** the border-colour field shows the palette's **name beside the hex**
|
||||
(`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
|
||||
@@ -153,15 +205,32 @@ 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 Shape button toggles circle/rect there, open the **Chat** scene and confirm the Shape button is
|
||||
**gone** (rect only) and that dragging the tile up and enlarging it fills a whole 607×540 area, then
|
||||
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
|
||||
into a circle there, open the **Chat** scene and confirm the **Circle** option is **gone** (rect only)
|
||||
and that dragging the tile up and enlarging it fills a whole 607×540 area, then
|
||||
switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
|
||||
eyeball.
|
||||
- Nothing else is queued for llamacasty — the creator queues work.
|
||||
|
||||
## Open (operational)
|
||||
- **Follow-up (not fixed — out of scope for TASK 61):** the **web source** block's geometry rows in
|
||||
`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.
|
||||
- **✅ 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
|
||||
@@ -186,8 +255,11 @@ 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: **429/430 passed, 0 warnings** (2026-10-21, this session — the vv Unit 2 gate run).
|
||||
The one failure was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
|
||||
- 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…`,
|
||||
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>A colour the border picker offers: its hex value and the plain-English
|
||||
/// 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)
|
||||
{
|
||||
/// <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.
|
||||
///
|
||||
/// <para>TASK 60 (the creator, 2026-10-21): *"in the drop down box, the #hex values are
|
||||
/// good, but add a corresponding color name/label next to the value."* The list used to
|
||||
/// be a hard-coded array in the click handler, which is why a name could not be added
|
||||
/// to it without touching the UI code.</para>
|
||||
/// </summary>
|
||||
public static class BorderColorPalette
|
||||
{
|
||||
/// <summary>The offered colours, in menu order.</summary>
|
||||
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
|
||||
{
|
||||
new("#FFFFFF", "White"),
|
||||
new("#000000", "Black"),
|
||||
new("#FF0000", "Red"),
|
||||
new("#00FF00", "Lime"),
|
||||
new("#0000FF", "Blue"),
|
||||
new("#FFFF00", "Yellow"),
|
||||
new("#FF00FF", "Magenta"),
|
||||
new("#00FFFF", "Cyan"),
|
||||
new("#FF8800", "Orange"),
|
||||
new("#8800FF", "Violet"),
|
||||
new("#e94560", "Crimson"),
|
||||
new("#22c55e", "Emerald"),
|
||||
new("#3b82f6", "Azure"),
|
||||
new("#f59e0b", "Amber"),
|
||||
};
|
||||
|
||||
/// <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) => 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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -120,7 +120,36 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
set
|
||||
{
|
||||
if (Set(ref _clipShape, value))
|
||||
Raise(nameof(ShapeButtonText));
|
||||
{
|
||||
Raise(nameof(IsRectShape));
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is the plain rectangle — the shape a
|
||||
/// camera has when none was ever picked (TASK 59: the properties panel shows
|
||||
/// the shape as a CHOICE, and Rect is the selected default).</summary>
|
||||
public bool IsRectShape
|
||||
{
|
||||
get => ClipShape != ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Round -> rect (restores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRectShape));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
|
||||
public bool IsRoundShape
|
||||
{
|
||||
get => ClipShape == ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Rect -> round (stores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +199,6 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
|
||||
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||
|
||||
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||
private WriteableBitmap? _videoImageSource;
|
||||
@@ -236,10 +264,26 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
{
|
||||
Raise(nameof(HasBorder));
|
||||
Raise(nameof(BorderBrush));
|
||||
Raise(nameof(BorderColorEntry));
|
||||
Raise(nameof(BorderColorOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
|
||||
+3
-2
@@ -7,12 +7,13 @@ 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`/`ShapeButtonText`, 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) |
|
||||
| `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 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 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 |
|
||||
|
||||
+196
@@ -2078,3 +2078,199 @@ that does not contain a dash (e.g. the `if (...) return false;` line instead of
|
||||
it), and put the new prose in the `newString` where any character is fine. When a whole stale block
|
||||
must go (e.g. half-overwritten comments), a three-line `python3` script that deletes the exact line
|
||||
range is faster and safer than fighting the matcher.
|
||||
|
||||
## 2026-10-21 — A WPF CONTROL WITH NO STYLE DRAWS THE LIGHT SYSTEM CHROME (RECIPE — TASK 58)
|
||||
|
||||
The creator asked why the Stream Alerts "Display" drop-down looked like it belonged to another
|
||||
app. The cause is not a colour: **setting `Background`/`Foreground`/`FontSize` on a WPF control
|
||||
changes its paint, not its TEMPLATE.** An unstyled `ComboBox` still renders the system template —
|
||||
grey border, system arrow, **white popup list** — so a dark blue box sat on the dark panel
|
||||
wearing light-system chrome. Unstyled `CheckBox` is the same story.
|
||||
|
||||
**The recipe (audit + fix + guard):**
|
||||
1. **Audit by grep, not by eye:** `grep -rn "<ComboBox\|<CheckBox" --include="*.xaml"` and look at
|
||||
each one's `Style` attribute. Hand-painted colours are the smell — they mean "no style".
|
||||
2. **Fix by style, never by more painting:** put the look in `Themes/Controls.xaml` once
|
||||
(`YtComboBox`, `YtCheckBox`, a `BasedOn` compact variant when the panel is dense) and delete
|
||||
the per-control colours. A dense panel needs a *variant* of the style, not a re-painted control.
|
||||
3. **Guard it with a test** that parses the XAML and fails when a control of that type has no
|
||||
style — otherwise the next hand-painted control walks the look back.
|
||||
4. **Check the inherited defaults while you are there:** a compact `BasedOn` variant must set the
|
||||
metrics it overrides (`FontSize`, `Padding`, `MinHeight`) or the panel silently changes density.
|
||||
|
||||
**Side effect worth naming:** the sweep also exposed a typo'd foreground (`#e0d0e0` instead of
|
||||
`#e0e0e0`) that only a single source of truth can prevent from recurring.
|
||||
|
||||
## 2026-10-21 — A CHOICE IN WPF: MUTUAL EXCLUSION BELONGS IN THE MODEL, NOT IN A RADIOBUTTON GROUP (RECIPE — TASK 59)
|
||||
|
||||
A "pick one of these" control (Rect | Circle) bound in WPF has two classic traps, both
|
||||
of which break the panel silently — the control keeps showing what it last showed while
|
||||
the model says something else.
|
||||
|
||||
**Trap 1 — the RadioButton group kills the binding.** A `RadioButton` group unchecks its
|
||||
siblings by setting `IsChecked` **in code** (`RadioButton.UncheckRadioButton` →
|
||||
`SetCurrentValueInternal`). WPF sees the target property being set outside the binding
|
||||
and **drops the binding** — the options stop reflecting the model for good
|
||||
("Losing the binding for radio button after I set the property manually in code",
|
||||
stackoverflow 7376373; the same trap in a template, 57275046).
|
||||
|
||||
**Trap 2 — a ToggleButton unchecks itself when clicked while checked.** A plain
|
||||
`ToggleButton` (unlike `RadioButton`) toggles `IsChecked` off when you click it while it
|
||||
is on. With a TwoWay binding the model receives `false` — and if the model refuses that
|
||||
write, the UI must be told again, or the option stays visibly unchecked while the model
|
||||
holds the truth.
|
||||
|
||||
**The recipe:**
|
||||
1. **Give the model one bool per option** (`IsRectShape`, `IsRoundShape`) — each
|
||||
settable, each doing the real work through the existing `Toggle…()` path so the
|
||||
side-effects (store/restore the pre-round rect) are not duplicated.
|
||||
2. **The setter ALWAYS re-raises** its own property name (`Raise(nameof(IsRectShape))`),
|
||||
even when the write changed nothing. The binding re-reads the getter and the UI snaps
|
||||
back to the model's truth — that is what makes a refused write (a locked shape)
|
||||
visible instead of sticky.
|
||||
3. **Raise BOTH option properties whenever the underlying value changes** (in the
|
||||
`ClipShape` setter), so the pair can never disagree on screen.
|
||||
4. **Do NOT use a `RadioButton` `GroupName`** for a bound pair. Two plain
|
||||
`ToggleButton`s (styled as segments) with model-side mutual exclusion keep their
|
||||
bindings alive (stackoverflow 50980687 measures the same behaviour with
|
||||
`IsChecked` bound to separate bools).
|
||||
5. **Show the CURRENT state, not the next action.** A button labelled "Round" when the
|
||||
camera is a rectangle tells the user what will happen; a selected "Rect" option tells
|
||||
them what IS. The creator called this out: *"Rect should be selected as a default if
|
||||
none exists."*
|
||||
|
||||
**Bonus:** the same "title owns its content, content is indented one level" rule applies
|
||||
to any dense settings panel — one indent level per ownership level, two when a title is
|
||||
itself nested (Strength under Blur under Background).
|
||||
|
||||
## 2026-10-21 — A LIST THE CREATOR MAY WANT TO EXTEND BELONGS IN A TESTABLE CATALOGUE (RECIPE — TASK 60)
|
||||
|
||||
The creator asked for one thing ("add a colour name next to the hex") and the code could not
|
||||
even express it: the drop-down's colours were a `string[]` **inside the click handler**. A
|
||||
list of bare strings cannot carry a name, and a list hidden in UI code cannot be asserted on
|
||||
without reading the UI code.
|
||||
|
||||
**The recipe (catalogue → UI consumer):**
|
||||
1. **Move the list out of the handler into a plain data type** — `NamedColor(Hex, Name)` +
|
||||
`static IReadOnlyList<NamedColor> All` in `Models/`. Models = "plain data types, no logic
|
||||
beyond what a property can carry" (see `Models/index.md`).
|
||||
2. **Give it a lookup** (`NameOf(hex)`, case-insensitive) that answers for a value the user
|
||||
typed, not only for the palette's own entries. **What it answers for a colour OUTSIDE the
|
||||
palette is a judgement call, and it changed once (TASK 62):** it used to fall back to the
|
||||
value itself, so the UI never showed an empty name — but "no name" is the honest answer, and
|
||||
the caller decides what to show (the border field shows the hex alone for a custom hex). The
|
||||
fallback that lies is worse than the empty string.
|
||||
3. **The UI becomes a consumer**: the handler iterates the catalogue and builds the rows. The
|
||||
handler must not know any colour by heart.
|
||||
4. **Guard it in the test**: every entry valid + named, no hex or name repeated, the colours
|
||||
that used to be offered are still offered (a name is added, never a colour dropped), the
|
||||
lookup answers, and **the old list is gone from the handler** (a source-level assertion:
|
||||
`Assert.False(source.Contains("\"#FFFFFF\""))`).
|
||||
5. **Name the colours so they do not collide** — the palette had two greens, two blues and two
|
||||
warm colours, so `#00FF00` is **Lime** and `#22c55e` **Emerald**, `#0000FF` **Blue** and
|
||||
`#3b82f6` **Azure**, `#FF8800` **Orange** and `#f59e0b` **Amber**. A name that repeats
|
||||
another's meaning is worse than no name.
|
||||
|
||||
**Why this matters beyond names:** the next request ("add a dark mode", "let me type a
|
||||
custom colour", "reorder them") is now a change in ONE file instead of a change inside a
|
||||
click handler.
|
||||
|
||||
## 2026-10-21 — A ROW OF SAME-KIND FIELDS NEEDS EXPLICIT WIDTHS (RECIPE — TASK 61)
|
||||
|
||||
The creator: *"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 row was a
|
||||
`DockPanel`, and WPF gave the four fields **two different widths** for no reason anyone chose.
|
||||
|
||||
**The trap (worth remembering on its own):**
|
||||
- A WPF element **sizes itself to its content** unless it is told otherwise. So the X/W fields
|
||||
(not the last child) were as wide as their text ("1920" ≈ 34px).
|
||||
- In a `DockPanel` the **LAST child fills the remaining space** — so the Y/H fields stretched to
|
||||
the full width of the row. Two widths for the same kind of field, purely by position.
|
||||
|
||||
**The recipe:**
|
||||
1. Give **every** field in the row an explicit `Width` (one value, repeated — the point is that
|
||||
they are EQUAL).
|
||||
2. Set `LastChildFill="False"` on the row, or the last field ignores its own `Width` and fills
|
||||
anyway.
|
||||
3. **Measure the width, don't guess it.** The needed width = the widest value the field can
|
||||
hold, measured in the app's own font at the field's size, plus the field's padding and
|
||||
border. WPF measures it for you, headlessly, with no window:
|
||||
|
||||
```csharp
|
||||
var typeface = new Typeface(SystemFonts.MessageFontFamily, FontStyles.Normal,
|
||||
FontWeights.Normal, FontStretches.Normal);
|
||||
var text = new FormattedText("-1920", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
|
||||
typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
|
||||
var needed = text + 8 /* padding */ + 2 /* border */;
|
||||
```
|
||||
|
||||
`SystemFonts.MessageFontFamily` is exactly what a control uses when no font is set, so the
|
||||
measurement matches what is drawn. (The geometry fields came out at 44px — 28 of text, 10 of
|
||||
chrome, 6 of slack.)
|
||||
4. **Assert the measurement in the test**, not a magic number: parse the XAML, take the widths,
|
||||
and fail if the row's width cannot hold the widest value. A hard-coded "44" in the test
|
||||
would pass even if the app's font changed and 44 stopped fitting.
|
||||
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.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
Foreground="#e0e0e0" Margin="0,0,0,12"/>
|
||||
|
||||
<!-- Show/hide the bar on the stream -->
|
||||
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Margin="0,0,0,10">
|
||||
<CheckBox Grid.Row="1" IsChecked="{Binding BarEnabled}" Style="{StaticResource YtCheckBox}" Margin="0,0,0,10">
|
||||
<TextBlock Text="Show the social bar on the stream" Foreground="#e0e0e0" FontSize="13"/>
|
||||
</CheckBox>
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
| 55 | **Disk hygiene: log rotation + low-disk guard** | ☐ Queued (2026-10-09) — `startup.log` appears uncapped; also temp alert-clip cleanup + free-space check before recording | [`TASKS/task-55-disk-hygiene.md`](TASKS/task-55-disk-hygiene.md) |
|
||||
| 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) |
|
||||
| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | ✅ **Both units shipped 2026-10-21** (Unit 1 = Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly. Unit 2 = Chat scene: rect only — the circle is not offered — the tile or one whole 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
|
||||
| 58 | **Properties-panel style consistency (the theme owns the look)** | ✅ Done — 2026-10-21 (the creator's "why is the drop-down not in the same style as the others?" — the drop-downs/fields/check boxes had been hand-painted instead of themed, so WPF drew the light system template; now every one carries a theme style, guarded by a test) | [`TASKS/task-58-properties-style-consistency.md`](TASKS/task-58-properties-style-consistency.md) |
|
||||
| 59 | **The properties panel is a hierarchy (titles own their content)** | ✅ Done — 2026-10-21 (the creator's ruling: a "Shape" title above the Mirror/Rect row, the drag hint indented under it, the same for Geometry / Border Color / Border Thickness, Remove + Blur under Background, Strength under Blur — and the shape became a CHOICE: Rect | Circle with Rect selected by default) | [`TASKS/task-59-properties-hierarchy.md`](TASKS/task-59-properties-hierarchy.md) |
|
||||
| 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,45 @@
|
||||
# TASK 58 — Properties-panel style consistency (the theme owns the look)
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator asked (2026-10-21), pointing at the Live scene → Stream Alerts layer →
|
||||
Properties → **Display**:
|
||||
|
||||
> why is the drop-down box not in the same style as the others? Can you run through the
|
||||
> existing elements in properties to ensure that we're maintaining a consistent style?
|
||||
|
||||
## Why the drop-down looked alien
|
||||
`Controls/LeftPanel.xaml` gave the drop-down a hand-painted `Background`/`Foreground`/
|
||||
`FontSize` instead of the theme's `YtComboBox` style. Setting colours does **not** change a
|
||||
control's template — WPF still drew the **light system ComboBox** (grey border, system
|
||||
arrow, white popup list) on top of the dark panel. The code even said so: the alert
|
||||
drop-down's comment says it was built "same shape as the chat Font selector above" — and the
|
||||
chat Font drop-down was unstyled too, so the inconsistency was copied.
|
||||
|
||||
## The sweep (the whole properties panel, then the app)
|
||||
| Control | Before | After |
|
||||
|---|---|---|
|
||||
| ComboBox ×2 (chat Font, alert Display) | hand-painted colours → light system template | `YtComboBox` (the same style as the bottom-bar/zoom drop-downs) |
|
||||
| TextBox ×16 in the properties panel | hand-painted colours, no template, no focus/hover, one typo'd foreground `#e0d0e0` | `YtTextBoxCompact` (BasedOn `YtTextBox`: 11px, padding 4,2) |
|
||||
| CheckBox ×2 (chat) | default light system check box | `YtCheckBox` (new dark style) |
|
||||
| CheckBox ×3 (preview broadcast panel) + ×1 (Socials dialog) | default light system check box | `YtCheckBox` |
|
||||
| TextBox (layer rename) | hand-painted colours | `YtTextBoxCompact` via `BasedOn`, borderless as before |
|
||||
| Buttons, pills, sliders | already themed | unchanged |
|
||||
|
||||
New theme styles (`Themes/Controls.xaml`):
|
||||
- `YtTextBoxCompact` — `BasedOn YtTextBox`, 11px + `Padding 4,2` + `MinHeight 22`. The
|
||||
properties panel is dense (11px labels); the full `YtTextBox` (13px, 8,6) would have
|
||||
doubled the panel's height.
|
||||
- `YtCheckBox` — a 14×14 rounded box + tick path, accent fill when checked, hover/focus
|
||||
border triggers. The default WPF check box is the light system one.
|
||||
|
||||
## The guard
|
||||
`ytLive.Tests/PropertiesStyleTests.cs` — the ONE test for this change:
|
||||
1. the theme defines `YtComboBox`/`YtCheckBox`/`YtTextBox`/`YtTextBoxCompact`;
|
||||
2. every `ComboBox` and `CheckBox` in the app's XAML carries the matching theme style;
|
||||
3. every field in the properties panel (`StackPanel x:Name="ElementsPanel"`) carries
|
||||
`YtTextBoxCompact` and sets no `Background`/`BorderBrush` of its own.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/PropertiesStyleTests.cs` — 431/431 green at ship time.
|
||||
@@ -0,0 +1,74 @@
|
||||
# TASK 59 — The properties panel is a hierarchy (titles own their content)
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator's ruling (2026-10-21), on the Live scene → Layers → webcam → Properties:
|
||||
|
||||
> Mirror Rect: needs a title "Shape" above it and **Rect should be selected as a default
|
||||
> if none exists** … also, the "drag to move" help text needs to be **indented under
|
||||
> "Mirror Rect" to show parentage** … same with **Geometry, Border Color, Border
|
||||
> Thickness**. Background → Remove: Remove should be indented under Background as should
|
||||
> Blur. Blur → Strength: Strength should be indented under Blur.
|
||||
|
||||
One rule covers all of it: **a title owns its content, and the content is indented one
|
||||
level (10px) under it.** Two levels deep when a title is itself nested (Strength under
|
||||
Blur under Background).
|
||||
|
||||
## The panel now reads
|
||||
```
|
||||
Shape ← NEW title
|
||||
[Mirror] [Rect] [Circle] ← the shape is a CHOICE, Rect selected by default
|
||||
Drag to move · drag the corner dot to resize
|
||||
Geometry ← title (already there), fields indented
|
||||
X [ ] Y [ ]
|
||||
W [ ] H [ ]
|
||||
Border Color ← promoted to a title, the field indented
|
||||
[#2c3a5c] [..]
|
||||
Border Thickness ← promoted to a title, the slider indented
|
||||
[slider] 3
|
||||
Background ← title, its rows indented
|
||||
Remove [pill]
|
||||
Blur [pill]
|
||||
Strength [slider] 50% ← one level deeper, under Blur
|
||||
```
|
||||
|
||||
## The shape: a choice, not a toggle
|
||||
Before: one button whose content was `ShapeButtonText` — the **next** action ("Round"
|
||||
when the camera was a rectangle). The creator could not tell what the camera *was*, only
|
||||
what it would become.
|
||||
|
||||
Now: two `SegmentToggle` options — **Rect** and **Circle** — bound to two new settable
|
||||
bools on `SceneElement`:
|
||||
- `IsRectShape` — true when the clip is the plain rectangle, which is what a camera is
|
||||
when no shape was ever picked (**the creator's "Rect is selected by default if none
|
||||
exists"**).
|
||||
- `IsRoundShape` — true when the clip is a circle.
|
||||
|
||||
Each setter flips the shape through `ToggleClipShape()` (so the pre-round rect is still
|
||||
stored/restored) and **always re-raises** — a refused write (the vertical Chat layout is
|
||||
rect-only, TASK 57) makes the UI snap back to the truth instead of sticking.
|
||||
|
||||
`ShapeButtonText` is gone (dead code after this change).
|
||||
|
||||
### Why the mutual exclusion lives in the model
|
||||
Not in a WPF `RadioButton` group: a RadioButton group unchecks its siblings by setting
|
||||
`IsChecked` **in code**, which makes WPF drop the binding ("Losing the binding for radio
|
||||
button after I set the property manually in code", stackoverflow 7376373) — the panel
|
||||
would silently stop reflecting the model. Two bool properties with model-side setters keep
|
||||
the binding alive (stackoverflow 50980687 / 57275046 describe the same trap).
|
||||
|
||||
## The circle option is still not offered where it must not be
|
||||
The Circle option's `Visibility` binds to `CanToggleClipShape` — false in the vertical
|
||||
tier's Chat layout (TASK 57) — so the option is not even shown there, and the model
|
||||
refuses the write anyway.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/PropertiesHierarchyTests.cs` — the ONE test for this change:
|
||||
- the model: Rect is selected by default → picking Circle makes the clip Round → picking
|
||||
Rect again restores it → the vertical Chat lock still refuses the circle;
|
||||
- the panel: the `Shape` title owns both options, the Circle option is the one gated by
|
||||
`CanToggleClipShape`, the drag hint is indented and follows the shape row, every titled
|
||||
block's content is indented 10px, and Strength sits at 20px (under Blur).
|
||||
|
||||
432/432 green at ship time.
|
||||
@@ -0,0 +1,57 @@
|
||||
# TASK 60 — The border-colour drop-down names every colour
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**:
|
||||
|
||||
> in the drop down box, the #hex values are good, but add a corresponding color name/label
|
||||
> next to the value
|
||||
|
||||
So each row of the drop-down now reads **`#e94560 Crimson`**: the hex stays exactly as
|
||||
it was, the colour's name goes next to it (dimmed, so the hex reads first), and the
|
||||
tooltip becomes **`Crimson (#e94560)`**.
|
||||
|
||||
## The palette is the single source of truth
|
||||
The 14 colours were a hard-coded `string[]` inside the click handler
|
||||
(`WebcamBorderColorPicker_Click` in `Controls/LeftPanel.xaml.cs`) — a list that could not
|
||||
carry a name, and a second place to edit if a colour were added. They now live in
|
||||
**`Models/BorderColorPalette.cs`**:
|
||||
|
||||
```csharp
|
||||
public readonly record struct NamedColor(string Hex, string Name);
|
||||
|
||||
public static class BorderColorPalette
|
||||
{
|
||||
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
|
||||
{
|
||||
new("#FFFFFF", "White"), new("#000000", "Black"), new("#FF0000", "Red"),
|
||||
new("#00FF00", "Lime"), new("#0000FF", "Blue"), new("#FFFF00", "Yellow"),
|
||||
new("#FF00FF", "Magenta"), new("#00FFFF", "Cyan"), new("#FF8800", "Orange"),
|
||||
new("#8800FF", "Violet"), new("#e94560", "Crimson"),new("#22c55e", "Emerald"),
|
||||
new("#3b82f6", "Azure"), new("#f59e0b", "Amber"),
|
||||
};
|
||||
|
||||
public static string NameOf(string hex); // case-insensitive; an unknown hex stays as-is
|
||||
}
|
||||
```
|
||||
|
||||
The names are chosen to be unambiguous next to each other (the palette has two greens,
|
||||
two blues and two warm colours: `#00FF00` is **Lime** and `#22c55e` **Emerald**;
|
||||
`#0000FF` is **Blue** and `#3b82f6` **Azure**; `#FF8800` is **Orange** and `#f59e0b`
|
||||
**Amber**).
|
||||
|
||||
`NameOf` also answers for a hex the creator **typed** into the field — the colours
|
||||
outside the palette simply keep their hex as the "name".
|
||||
|
||||
## The menu row
|
||||
The `MenuItem`'s `Header` is a horizontal `StackPanel` (the theme's `MenuItem` template
|
||||
presents the header through a `ContentPresenter`, so any content works): the hex in the
|
||||
normal foreground, the name dimmed (`#a0a0b0`) with a 10px gap. The swatch `Icon` is
|
||||
unchanged.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/BorderColorPaletteTests.cs` — the ONE test for this change: every entry has
|
||||
a valid hex and a name, no hex or name repeats, the 14 colours the picker used to offer
|
||||
are all still offered, `NameOf` answers in any case and falls back to the hex, and the
|
||||
picker builds its menu from the palette (no second list left in the code-behind).
|
||||
@@ -0,0 +1,43 @@
|
||||
# TASK 61 — The geometry fields share one fixed width
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Geometry**:
|
||||
|
||||
> the Y and H boxes under webcam properties 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
|
||||
> providing the geometry values
|
||||
|
||||
## Why they looked different
|
||||
The row is a `DockPanel`:
|
||||
|
||||
```
|
||||
[X label][X field][ Y label][Y field]
|
||||
```
|
||||
|
||||
A WPF element **sizes itself to its content** unless it is told otherwise, and in a
|
||||
`DockPanel` the **last child fills the remaining space**. So the X/W fields (not last)
|
||||
were as wide as their text ("1920" ≈ 34px) while the Y/H fields (last) stretched to the
|
||||
full width of the row — two different widths for the same kind of field.
|
||||
|
||||
## The fix
|
||||
- **All four fields carry the same explicit `Width="44"`** — wide enough for the largest
|
||||
geometry value (the master frame is 1920×1080 → four digits) **plus a sign** the creator
|
||||
may be typing, and no wider.
|
||||
- **The rows set `LastChildFill="False"`** — otherwise the last field would still stretch
|
||||
to the row's width regardless of its own `Width`.
|
||||
|
||||
44px = the measured width of `-1920` in the app's font at 11px (≈28px) + the field's
|
||||
padding (8) + its border (2) + slack.
|
||||
|
||||
## The test
|
||||
`ytLive.Tests/GeometryFieldWidthTests.cs` — the ONE test for this change: the four
|
||||
fields carry the same **explicit** width (no field sizes itself to its content), and that
|
||||
width really fits the widest value — measured with WPF's `FormattedText` in the app's own
|
||||
font (`SystemFonts.MessageFontFamily`) at the field's 11px, plus the padding and border.
|
||||
|
||||
## Not done (out of scope, noted in `HANDOFF.md`)
|
||||
The **web source** block's geometry rows (`Controls/LeftPanel.xaml`, the
|
||||
`SelectedElement.IsWebSource` panel) have the identical shape and the identical defect —
|
||||
same fix, one line each, when the creator wants it.
|
||||
@@ -0,0 +1,42 @@
|
||||
# TASK 62 — The border-colour field shows the colour's name next to the hex
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-21.
|
||||
|
||||
## What it is
|
||||
The creator (2026-10-21), on Live → Layers → webcam → Properties → **Border Color**,
|
||||
after TASK 60 gave the drop-down list its names:
|
||||
|
||||
> the border color drop drown box content should also include the name of the color next
|
||||
> to the hex value as they in the drop-down list box
|
||||
|
||||
So the row that shows the **current** colour now reads the same way the list rows do:
|
||||
|
||||
```
|
||||
Border Color
|
||||
[#2c3a5c] Azure [...]
|
||||
```
|
||||
|
||||
## How it is done
|
||||
- **`SceneElement.BorderColorName`** — the palette's name for the current border colour,
|
||||
raised whenever `BorderColor` changes. **Empty for a custom hex** the creator typed
|
||||
(there is no name for it — the field shows the hex alone).
|
||||
- The **hex field keeps a fixed width** (`Width="60"`): it used to fill the row, so a name
|
||||
beside it would have drifted to the far edge instead of sitting next to the hex.
|
||||
- The name is a dimmed `TextBlock` (`#a0a0b0`, 11px) in the same horizontal row as the
|
||||
field, after it — the same reading order as the drop-down list (`#e94560 Crimson`).
|
||||
|
||||
**The field stays editable** — the hex is still typed into it (the picker's `...` button
|
||||
opens the named list). Making the field read-only with a combined "#hex Name" string was
|
||||
the alternative; it would have taken away typing a custom hex, so the name sits beside
|
||||
the field instead.
|
||||
|
||||
## `BorderColorPalette.NameOf` changed
|
||||
It used to fall back to **the hex itself** for a colour outside the palette ("there is no
|
||||
name to give it"). It now returns an **empty string** — "no name" is the honest answer,
|
||||
and the caller decides what to show (the field shows the hex alone). The TASK 60 test
|
||||
assertion was updated with it.
|
||||
|
||||
## Tests
|
||||
`ytLive.Tests/BorderColorNameTests.cs` — the ONE test for this change: the model carries
|
||||
the name ("Crimson") and raises it when the colour changes, a custom hex has no name, the
|
||||
hex field has a fixed width, and the name sits in the same row as the field, after it.
|
||||
@@ -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`).
|
||||
@@ -122,6 +122,65 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Dense variant of YtTextBox for the properties panel (11px labels, tight
|
||||
padding). Same template + colours as YtTextBox — only the metrics differ,
|
||||
so the panel keeps its rhythm without hand-painting every field. -->
|
||||
<Style x:Key="YtTextBoxCompact" TargetType="TextBox" BasedOn="{StaticResource YtTextBox}">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Padding" Value="4,2"/>
|
||||
<Setter Property="MinHeight" Value="22"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- Dark check box. The default WPF template is the light system one — it
|
||||
sticks out of the dark panels, so every check box carries this style
|
||||
(the guard test in ytLive.Tests/PropertiesStyleTests.cs enforces it). -->
|
||||
<Style x:Key="YtCheckBox" TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<Grid Background="Transparent">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Box" Grid.Column="0" Width="14" Height="14" CornerRadius="3"
|
||||
Background="#0f3460" BorderBrush="#2a3a5e" BorderThickness="1"
|
||||
VerticalAlignment="Center">
|
||||
<Path x:Name="Tick" Data="M2.5,7.2 L5.8,10.5 L11.5,3.8"
|
||||
Stroke="#ffffff" StrokeThickness="2"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||
Visibility="Collapsed"/>
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" Margin="7,0,0,0" VerticalAlignment="Center"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="#3d5076"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocusWithin" Value="True">
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="#e94560"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="#e94560"/>
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="#e94560"/>
|
||||
<Setter TargetName="Tick" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="YtComboBox" TargetType="ComboBox">
|
||||
<Setter Property="Foreground" Value="#e0e0e0"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
@@ -701,5 +760,6 @@
|
||||
<Helpers:HiddenBoolToVisibilityConverter x:Key="HiddenBoolToVis"/>
|
||||
<Helpers:EnumToBoolConverter x:Key="EnumToBool"/>
|
||||
<Helpers:AllTrueToVisibilityConverter x:Key="AllTrueToVis"/>
|
||||
<Helpers:HexToBrushConverter x:Key="HexToBrush"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
+22
-1
@@ -5,7 +5,7 @@ memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtComboBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
|
||||
| `Controls.xaml` | All custom control styles/templates: `YtButton`, `YtButtonSecondary`, `IconButton`, `YtTextBox`, `YtTextBoxCompact` (the dense variant the properties panel uses), `YtComboBox`, `YtCheckBox`, scrollbar/slider/tooltip/context-menu/menu-item/separator/list-item (implicit where app-wide), `YtListBoxItem`, `YtLabel`, `SectionHeader` |
|
||||
|
||||
How it's wired:
|
||||
- Merged once in [`App.xaml`](../App.xaml) — app-wide. New windows get the theme
|
||||
@@ -19,6 +19,27 @@ Gotchas learned the hard way:
|
||||
- A custom `ComboBox` template must put the `ContentPresenter` (`SelectionBoxItem`)
|
||||
**inside the ComboBox template, not the ToggleButton template** — the latter
|
||||
resolves against the wrong TemplatedParent and renders nothing.
|
||||
- **A control with no style renders with the LIGHT WPF system template** — a
|
||||
hand-painted `Background`/`Foreground` colours the box but leaves the system
|
||||
border, the system arrow and the white popup list. That is why the Stream
|
||||
Alerts "Display" drop-down looked like it belonged to another app (TASK 58,
|
||||
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,48 @@ 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)
|
||||
- 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
|
||||
outside the palette, "no name" being the honest answer), NOT from a list inside
|
||||
the click handler — the creator asked for a colour NAME next to each hex (`#e94560 Crimson`), and a
|
||||
hard-coded `string[]` cannot carry one. A palette the creator may want to extend belongs in a
|
||||
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
|
||||
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
|
||||
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
|
||||
`ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
- Resolution tiers: 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. Bottom bar: gear icon far left, stream stats (bitrate/FPS/dropped/duration) centered under preview, resolution dropdown far right. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
|
||||
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 60 — the creator (2026-10-21): *"under Live->Layers->webcam->
|
||||
/// Properties->border color: in the drop down box, the #hex values are good, but add a
|
||||
/// corresponding color name/label next to the value."*
|
||||
///
|
||||
/// <para>One test: the palette is the single source of truth for the drop-down (the
|
||||
/// picker builds its menu from it and carries no list of its own), every entry has a
|
||||
/// valid hex and a name, the names are unique, the colours the picker used to offer are
|
||||
/// all still offered, and the lookup answers for a hex the creator typed.</para></summary>
|
||||
public class BorderColorPaletteTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_border_palette_names_every_colour_the_picker_offers()
|
||||
{
|
||||
// 1. Every entry is a valid hex with a name, and no hex or name repeats.
|
||||
Assert.NotEmpty(BorderColorPalette.All);
|
||||
foreach (var colour in BorderColorPalette.All)
|
||||
{
|
||||
Assert.Matches("^#[0-9a-fA-F]{6}$", colour.Hex);
|
||||
Assert.False(string.IsNullOrWhiteSpace(colour.Name), $"{colour.Hex} has no name");
|
||||
}
|
||||
Assert.Equal(BorderColorPalette.All.Count,
|
||||
BorderColorPalette.All.Select(c => c.Hex).Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
Assert.Equal(BorderColorPalette.All.Count,
|
||||
BorderColorPalette.All.Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
|
||||
// 2. The colours the picker used to offer are all still offered.
|
||||
string[] legacy = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF", "#FFFF00",
|
||||
"#FF00FF", "#00FFFF", "#FF8800", "#8800FF", "#e94560", "#22c55e",
|
||||
"#3b82f6", "#f59e0b"];
|
||||
foreach (var hex in legacy)
|
||||
Assert.Contains(BorderColorPalette.All, c => c.Hex == hex);
|
||||
|
||||
// 3. The lookup answers for a hex in any case, and a colour outside the palette
|
||||
// has NO name (the field shows the hex alone — TASK 62).
|
||||
Assert.Equal("Crimson", BorderColorPalette.NameOf("#e94560"));
|
||||
Assert.Equal("Crimson", BorderColorPalette.NameOf("#E94560"));
|
||||
Assert.Equal("White", BorderColorPalette.NameOf(" #ffffff "));
|
||||
Assert.Equal("", BorderColorPalette.NameOf("#123456"));
|
||||
|
||||
// 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.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 61 — the creator (2026-10-21): *"the Y and H boxes under webcam
|
||||
/// properties should be the same width as the X and W boxes and those widths should be
|
||||
/// fixed to accommodate the max number of possible digits providing the geometry
|
||||
/// values."*
|
||||
///
|
||||
/// <para>One test: the four geometry fields carry the SAME explicit width (the Y/H
|
||||
/// fields used to stretch to fill the row, because they were the last child of a
|
||||
/// DockPanel, while X/W sized themselves to their content), and that width really fits
|
||||
/// the widest value the panel can show — the largest geometry value (1920 master
|
||||
/// pixels) with a sign, measured in the app's own font at the field's size.</para></summary>
|
||||
public class GeometryFieldWidthTests
|
||||
{
|
||||
/// <summary>The widest value a geometry field can show: the master frame is
|
||||
/// 1920×1080, so four digits — plus a sign the creator may be typing.</summary>
|
||||
private const string WidestValue = "-1920";
|
||||
|
||||
[Fact]
|
||||
public void The_webcam_geometry_fields_share_one_fixed_width_that_fits_the_largest_value()
|
||||
{
|
||||
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
|
||||
var geometry = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
||||
|
||||
var rows = geometry.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel").ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
|
||||
var fields = rows.SelectMany(r => r.Elements().Where(e => e.Name.LocalName == "TextBox")).ToList();
|
||||
Assert.Equal(4, fields.Count);
|
||||
|
||||
// Every field carries the SAME explicit width — a field without one sizes
|
||||
// itself to its content, and the last one in the row stretches to fill it.
|
||||
var widths = fields.Select(f => (string?)f.Attribute("Width")).ToList();
|
||||
Assert.All(widths, w => Assert.False(string.IsNullOrWhiteSpace(w),
|
||||
"a geometry field has no fixed width — it sizes itself to its content"));
|
||||
Assert.Single(widths.Distinct());
|
||||
|
||||
// And that width fits the widest value the panel can show, in the app's font
|
||||
// at the field's size, plus the field's own padding and border.
|
||||
var width = double.Parse(widths[0]!, CultureInfo.InvariantCulture);
|
||||
var needed = MeasureWidestValue() + 8 /* YtTextBoxCompact padding */ + 2 /* border */;
|
||||
Assert.True(width >= needed,
|
||||
$"the geometry fields are {width:0}px wide; {needed:0.#}px are needed for '{WidestValue}'");
|
||||
}
|
||||
|
||||
/// <summary>The width of the widest possible value, in the app's font at 11px.</summary>
|
||||
private static double MeasureWidestValue()
|
||||
{
|
||||
var typeface = new Typeface(SystemFonts.MessageFontFamily,
|
||||
FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
|
||||
return new FormattedText(WidestValue, CultureInfo.CurrentCulture,
|
||||
FlowDirection.LeftToRight, typeface, 11, Brushes.Black, pixelsPerDip: 1.0).Width;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 59 — the creator's properties-panel hierarchy (2026-10-21):
|
||||
/// "Mirror Rect: needs a title 'Shape' above it and Rect should be selected as a default
|
||||
/// if none exists … 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."
|
||||
///
|
||||
/// <para>One test, the whole story: the shape is a CHOICE and Rect is the selected
|
||||
/// default (the model), the options live under a "Shape" title with the drag hint
|
||||
/// indented beneath them, every titled block owns its content one indent deeper, and the
|
||||
/// strength row sits under Blur — and the TASK 57 rule still holds: the circle option is
|
||||
/// not offered in the vertical Chat layout, and the model refuses it there.</para></summary>
|
||||
public class PropertiesHierarchyTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_webcam_properties_show_a_Shape_title_with_Rect_selected_and_indent_their_content()
|
||||
{
|
||||
// ── The model: the shape is a choice, and Rect is the default ─────────
|
||||
var camera = new WebcamSceneConfig { WebcamId = "webcam-1", Name = "Webcam" };
|
||||
Assert.True(camera.IsRectShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
|
||||
camera.IsRoundShape = true;
|
||||
Assert.Equal(ClipShape.Round, camera.ClipShape);
|
||||
Assert.False(camera.IsRectShape);
|
||||
Assert.True(camera.IsRoundShape);
|
||||
|
||||
camera.IsRectShape = true;
|
||||
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
||||
Assert.True(camera.IsRectShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
|
||||
// The vertical Chat layout locks the shape to a rect — the circle option is
|
||||
// not offered there, and the model refuses it even if the UI asks.
|
||||
camera.VerticalShapeLocked = true;
|
||||
camera.IsRoundShape = true;
|
||||
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
||||
Assert.False(camera.IsRoundShape);
|
||||
Assert.True(camera.IsRectShape);
|
||||
|
||||
// ── The panel: a Shape title owns the options, the hint is indented ───
|
||||
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
|
||||
var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == "Shape");
|
||||
Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
|
||||
|
||||
var rectOption = OptionBoundTo(doc, "IsRectShape");
|
||||
var circleOption = OptionBoundTo(doc, "IsRoundShape");
|
||||
Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
|
||||
|
||||
// The CIRCLE is the option that is not offered in the vertical Chat layout.
|
||||
Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
|
||||
|
||||
// Both options live under the Shape title.
|
||||
var shapeBlock = shapeTitle.Parent!;
|
||||
Assert.Contains(rectOption, shapeBlock.Descendants());
|
||||
Assert.Contains(circleOption, shapeBlock.Descendants());
|
||||
|
||||
// The drag hint is indented, and it comes after the shape row — it reads as
|
||||
// part of the shape block.
|
||||
var hint = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& ((string?)e.Attribute("Text") ?? "").StartsWith("Drag to move"));
|
||||
Assert.True(LeftIndent(hint) > 0, "the drag hint is not indented under the shape row");
|
||||
var siblings = shapeBlock.Parent!.Elements().ToList();
|
||||
Assert.True(siblings.IndexOf(shapeBlock) < siblings.IndexOf(hint),
|
||||
"the drag hint comes before the shape row");
|
||||
|
||||
// ── Every titled block owns its content one indent deeper ────────────
|
||||
var geometryTitle = doc.Descendants()
|
||||
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
||||
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
||||
Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
|
||||
foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
|
||||
Assert.Equal(10, LeftIndent(row));
|
||||
|
||||
// The webcam block (the one the TASK 51 test finds by its binding).
|
||||
var webcamPanel = doc.Descendants().Single(e => e.Name.LocalName == "StackPanel"
|
||||
&& ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam"));
|
||||
|
||||
foreach (var title in new[] { "Border Color", "Border Thickness" })
|
||||
{
|
||||
var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == title);
|
||||
Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
|
||||
Assert.Equal(10, LeftIndent(RowAfter(label)));
|
||||
}
|
||||
|
||||
// Remove and Blur are indented under Background; Strength one level deeper,
|
||||
// under Blur.
|
||||
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Remove")));
|
||||
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Blur")));
|
||||
Assert.Equal(20, LeftIndent(RowContaining(webcamPanel, "Strength")));
|
||||
}
|
||||
|
||||
/// <summary>The shape option bound to the given model property.</summary>
|
||||
private static XElement OptionBoundTo(XDocument doc, string property) =>
|
||||
doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
|
||||
&& ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
|
||||
|
||||
/// <summary>The row (DockPanel) that holds the given label.</summary>
|
||||
private static XElement RowContaining(XElement scope, string label) =>
|
||||
scope.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
||||
&& (string?)e.Attribute("Text") == label).Parent!;
|
||||
|
||||
/// <summary>The row that follows a title.</summary>
|
||||
private static XElement RowAfter(XElement title)
|
||||
{
|
||||
var next = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>The left inset of an element's Margin, in pixels.</summary>
|
||||
private static int LeftIndent(XElement element)
|
||||
{
|
||||
var margin = (string?)element.Attribute("Margin");
|
||||
if (string.IsNullOrWhiteSpace(margin)) return 0;
|
||||
var first = margin.Split(',')[0].Trim();
|
||||
return int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>TASK 58 — the creator's style audit (2026-10-21): "in the Live scene, for the
|
||||
/// layer Stream Alerts, the properties → display — why is the drop-down box not in the same
|
||||
/// style as the others?" It was never given the theme's <c>YtComboBox</c> style — only a
|
||||
/// hand-painted Background/Foreground — so WPF fell back to the light system template, and
|
||||
/// the chat Font drop-down (copied from it) was the same. The sweep found the same smell in
|
||||
/// every check box and every properties field.
|
||||
///
|
||||
/// <para>This test is the guard: every drop-down and check box in the app carries the theme's
|
||||
/// style, and every field in the properties panel carries the compact theme style and paints
|
||||
/// no colour of its own — the theme owns the look, so it cannot drift back.</para></summary>
|
||||
public class PropertiesStyleTests
|
||||
{
|
||||
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||
|
||||
[Fact]
|
||||
public void Every_drop_down_check_box_and_properties_field_carries_the_theme_style()
|
||||
{
|
||||
// 1. The theme defines the styles the app relies on.
|
||||
var theme = XDocument.Load(SourceAssert.RepoPath("Themes", "Controls.xaml"));
|
||||
var keys = theme.Descendants()
|
||||
.Where(e => e.Name.LocalName == "Style")
|
||||
.Select(e => (string?)e.Attribute(Xaml + "Key"))
|
||||
.Where(k => k != null)
|
||||
.ToHashSet();
|
||||
foreach (var required in new[] { "YtComboBox", "YtCheckBox", "YtTextBox", "YtTextBoxCompact" })
|
||||
Assert.Contains(required, keys);
|
||||
|
||||
// 2. Every drop-down and check box in the app carries the theme style — an unstyled
|
||||
// one falls back to the light WPF system template.
|
||||
foreach (var file in AppXamlFiles())
|
||||
{
|
||||
var doc = XDocument.Load(file);
|
||||
var where = Path.GetFileName(file);
|
||||
|
||||
foreach (var combo in doc.Descendants().Where(e => e.Name.LocalName == "ComboBox"))
|
||||
AssertStyled(combo, "YtComboBox",
|
||||
$"{where}: a ComboBox is not styled with YtComboBox — it renders with the light system template");
|
||||
|
||||
foreach (var check in doc.Descendants().Where(e => e.Name.LocalName == "CheckBox"))
|
||||
AssertStyled(check, "YtCheckBox",
|
||||
$"{where}: a CheckBox is not styled with YtCheckBox — it renders with the light system template");
|
||||
}
|
||||
|
||||
// 3. Every field in the properties panel carries the compact theme style and paints
|
||||
// no colours of its own.
|
||||
var left = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
||||
var properties = left.Descendants().Single(e => e.Name.LocalName == "StackPanel"
|
||||
&& (string?)e.Attribute(Xaml + "Name") == "ElementsPanel");
|
||||
|
||||
var fields = properties.Descendants().Where(e => e.Name.LocalName == "TextBox").ToList();
|
||||
Assert.NotEmpty(fields);
|
||||
foreach (var box in fields)
|
||||
{
|
||||
AssertStyled(box, "YtTextBoxCompact",
|
||||
"a properties field is not styled with YtTextBoxCompact");
|
||||
Assert.Null(box.Attribute("Background"));
|
||||
Assert.Null(box.Attribute("BorderBrush"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The app's XAML: the windows at the repo root plus the carved-up controls.</summary>
|
||||
private static string[] AppXamlFiles()
|
||||
{
|
||||
var root = Path.GetDirectoryName(SourceAssert.RepoPath("ytLive.csproj"))!;
|
||||
return Directory.GetFiles(root, "*.xaml", SearchOption.TopDirectoryOnly)
|
||||
.Concat(Directory.GetFiles(Path.Combine(root, "Controls"), "*.xaml", SearchOption.TopDirectoryOnly))
|
||||
.OrderBy(f => f)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Asserts the element carries the named theme style.</summary>
|
||||
private static void AssertStyled(XElement element, string key, string message)
|
||||
{
|
||||
var style = (string?)element.Attribute("Style");
|
||||
Assert.True(style is not null && style.Contains(key, StringComparison.Ordinal), message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user