fix(text): show selected formatting and style controls

Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
This commit is contained in:
2026-11-04 17:05:06 -08:00
parent 5c4acf141b
commit 5f7bcc2425
15 changed files with 417 additions and 52 deletions
+51 -42
View File
@@ -2,8 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:models="clr-namespace:ytLive.Models"
xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore">
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
@@ -321,47 +320,46 @@
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<TextBlock Text="Text" Style="{StaticResource PropertyHeader}"/>
<TextBlock Text="Static text — it will not animate automatically. Move and resize it in the preview."
Style="{StaticResource PropertyHint}" Margin="0,0,0,6" TextWrapping="Wrap"/>
Style="{StaticResource PropertyHint}" Margin="10,0,0,6" TextWrapping="Wrap"/>
<TextBox x:Name="TextLayerEditor" Text="{Binding SelectedElement.TextContent, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource YtTextBoxCompact}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120"
VerticalScrollBarVisibility="Auto" MinHeight="72" MaxHeight="120" Margin="10,0,0,8"
AutomationProperties.Name="Text layer content"/>
<TextBlock Text="Font" Style="{StaticResource PropertyHeader}"/>
<ComboBox IsEditable="True" ItemsSource="{x:Static media:Fonts.SystemFontFamilies}"
DisplayMemberPath="Source"
Text="{Binding SelectedElement.TextFontFamily, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtComboBox}" Height="26" Margin="0,0,0,6"
AutomationProperties.Name="Text layer font"/>
<DockPanel Margin="0,0,0,6" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Size" Style="{StaticResource PropertyFieldLabel}"
Width="42" VerticalAlignment="Center"/>
<ComboBox IsEditable="True"
Text="{Binding SelectedElement.TextFontSize, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtComboBox}" Height="26" Width="76"
AutomationProperties.Name="Text layer size">
<ComboBoxItem Content="12"/><ComboBoxItem Content="16"/>
<ComboBoxItem Content="20"/><ComboBoxItem Content="24"/>
<ComboBoxItem Content="32"/><ComboBoxItem Content="36"/>
<ComboBoxItem Content="48"/><ComboBoxItem Content="60"/>
<ComboBoxItem Content="72"/><ComboBoxItem Content="96"/>
<ComboBoxItem Content="120"/><ComboBoxItem Content="144"/>
<ComboBoxItem Content="180"/><ComboBoxItem Content="240"/>
<ComboBoxItem Content="300"/>
</ComboBox>
</DockPanel>
<DockPanel Margin="0,0,0,6" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Color" Style="{StaticResource PropertyFieldLabel}"
Width="42" VerticalAlignment="Center"/>
<TextBlock Text="Typography" Style="{StaticResource PropertyHeader}"/>
<StackPanel Margin="10,0,0,6">
<TextBlock Text="Font Type" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
<ComboBox x:Name="TextLayerFontTypePicker"
ItemsSource="{Binding TextFontTypeOptions}"
SelectedItem="{Binding SelectedTextFontType, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"/>
<TextBlock Text="Font" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
<ComboBox x:Name="TextLayerFontPicker"
ItemsSource="{Binding TextFontFamilies}"
SelectedItem="{Binding SelectedTextFontFamily, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30" Margin="0,0,0,6"
AutomationProperties.Name="Text layer font"/>
<TextBlock Text="Size" Style="{StaticResource PropertyFieldLabel}" Margin="0,0,0,3"/>
<ComboBox x:Name="TextLayerSizePicker"
ItemsSource="{Binding TextFontSizeOptions}"
SelectedItem="{Binding SelectedElement.TextFontSize, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30" Width="92"
HorizontalAlignment="Left" AutomationProperties.Name="Text layer size"/>
</StackPanel>
<TextBlock Text="Color" Style="{StaticResource PropertyHeader}"/>
<DockPanel Margin="10,0,0,6" LastChildFill="False">
<TextBox x:Name="TextLayerColorEditor"
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"
Style="{StaticResource YtTextBoxCompact}" MaxLength="9" Width="82"
ToolTip="#RRGGBB or #AARRGGBB" AutomationProperties.Name="Text layer color"/>
<Border Width="18" Height="18" Margin="8,0,0,0" CornerRadius="2"
Background="{Binding SelectedElement.TextColor, Converter={StaticResource HexToBrush}}"
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
VerticalAlignment="Center"/>
<ComboBox x:Name="TextLayerColorPicker"
ItemsSource="{Binding SelectedElement.TextColorOptions}"
SelectedItem="{Binding SelectedElement.TextColorEntry, Mode=TwoWay}"
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
Style="{StaticResource YtComboBox}" MinWidth="112" Margin="8,0,0,0"
ToolTip="Choose a text color" AutomationProperties.Name="Text layer color palette"/>
</DockPanel>
<WrapPanel Margin="0,0,0,4">
<TextBlock Text="Emphasis" Style="{StaticResource PropertyHeader}"/>
<WrapPanel Margin="10,0,0,4">
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Bold" Margin="0,0,8,4"
IsChecked="{Binding SelectedElement.TextBold, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Italic" Margin="0,0,8,4"
@@ -370,13 +368,24 @@
IsChecked="{Binding SelectedElement.TextUnderline, Mode=TwoWay}"/>
</WrapPanel>
<TextBlock Text="Alignment" Style="{StaticResource PropertyHeader}"/>
<ComboBox SelectedValuePath="Tag"
SelectedValue="{Binding SelectedElement.TextAlignment, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="26">
<ComboBoxItem Content="Left" Tag="{x:Static models:TextLayerAlignment.Left}"/>
<ComboBoxItem Content="Center" Tag="{x:Static models:TextLayerAlignment.Center}"/>
<ComboBoxItem Content="Right" Tag="{x:Static models:TextLayerAlignment.Right}"/>
</ComboBox>
<DockPanel Margin="10,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="Horizontal" Style="{StaticResource PropertyFieldLabel}"
Width="72" VerticalAlignment="Center"/>
<ComboBox x:Name="TextLayerHorizontalAlignmentPicker"
ItemsSource="{Binding TextHorizontalAlignmentOptions}"
SelectedItem="{Binding SelectedElement.TextAlignment, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30"
AutomationProperties.Name="Horizontal text alignment"/>
</DockPanel>
<DockPanel Margin="10,0,0,0">
<TextBlock DockPanel.Dock="Left" Text="Vertical" Style="{StaticResource PropertyFieldLabel}"
Width="72" VerticalAlignment="Center"/>
<ComboBox x:Name="TextLayerVerticalAlignmentPicker"
ItemsSource="{Binding TextVerticalAlignmentOptions}"
SelectedItem="{Binding SelectedElement.TextVerticalAlignment, Mode=TwoWay}"
Style="{StaticResource YtComboBox}" Height="30"
AutomationProperties.Name="Vertical text alignment"/>
</DockPanel>
</StackPanel>
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
+1 -1
View File
@@ -12,7 +12,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font, size, color, emphasis, and alignment with a static-text notice. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
+29 -1
View File
@@ -48,6 +48,13 @@ public enum TextLayerAlignment
Right
}
public enum TextLayerVerticalAlignment
{
Top,
Center,
Bottom
}
public enum ClipShape
{
Traditional,
@@ -423,9 +430,22 @@ public class Source : SceneElement
public string TextColor
{
get => _textColor;
set => Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF");
set
{
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
Raise(nameof(TextColorEntry));
Raise(nameof(TextColorOptions));
}
}
public NamedColor TextColorEntry
{
get => BorderColorPalette.EntryFor(TextColor);
set => TextColor = value.Hex;
}
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
private bool _textBold;
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
@@ -442,6 +462,14 @@ public class Source : SceneElement
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
}
private TextLayerVerticalAlignment _textVerticalAlignment;
public TextLayerVerticalAlignment TextVerticalAlignment
{
get => _textVerticalAlignment;
set => Set(ref _textVerticalAlignment,
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
}
private static bool IsValidTextColor(string? value)
{
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
+1 -1
View File
@@ -7,7 +7,7 @@ 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 (one per layout)**/**Ticker (one per scene)**/Blur), `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`), **TextOverlay content + font/size/color/emphasis/alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `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`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
| `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) |
+5 -1
View File
@@ -113,7 +113,7 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment
TextUnderline, TextAlignment, TextVerticalAlignment
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -179,6 +179,10 @@ public partial class LayoutStore : IDisposable
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
? TextLayerAlignment.Left
: (TextLayerAlignment)reader.GetInt32(48),
TextVerticalAlignment = reader.IsDBNull(49)
|| !Enum.IsDefined((TextLayerVerticalAlignment)reader.GetInt32(49))
? TextLayerVerticalAlignment.Top
: (TextLayerVerticalAlignment)reader.GetInt32(49),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
+6
View File
@@ -339,6 +339,12 @@ public partial class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("TextVerticalAlignment"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextVerticalAlignment INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("WebUri"))
{
+4 -2
View File
@@ -94,7 +94,7 @@ public partial class LayoutStore : IDisposable
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
TextUnderline, TextAlignment)
TextUnderline, TextAlignment, TextVerticalAlignment)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -104,7 +104,7 @@ public partial class LayoutStore : IDisposable
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
$textUnderline, $textAlignment)
$textUnderline, $textAlignment, $textVerticalAlignment)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -156,6 +156,7 @@ public partial class LayoutStore : IDisposable
var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer);
var textVerticalAlignmentP = cmd.Parameters.Add("$textVerticalAlignment", SqliteType.Integer);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -216,6 +217,7 @@ public partial class LayoutStore : IDisposable
textItalicP.Value = source.TextItalic ? 1 : 0;
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
textAlignmentP.Value = (int)source.TextAlignment;
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
cmd.ExecuteNonQuery();
}
}
+9 -1
View File
@@ -59,6 +59,7 @@ public sealed class TextOverlayRenderer
source.TextItalic,
source.TextUnderline,
source.TextAlignment,
source.TextVerticalAlignment,
RasterDimension(source.Width),
RasterDimension(source.Height));
@@ -100,7 +101,13 @@ public sealed class TextOverlayRenderer
};
if (config.Underline)
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
drawing.DrawText(formatted, new Point(0, 0));
var top = config.VerticalAlignment switch
{
TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2),
TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height),
_ => 0,
};
drawing.DrawText(formatted, new Point(0, top));
}
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
@@ -145,6 +152,7 @@ public sealed class TextOverlayRenderer
bool Italic,
bool Underline,
TextLayerAlignment Alignment,
TextLayerVerticalAlignment VerticalAlignment,
int Width,
int Height);
}
+1 -1
View File
@@ -16,7 +16,7 @@ External-facing logic: YouTube API, persistence. See
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) |
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain. The shared ticker renderer serves the AlertBox's active event and scene-local `SourceType.Ticker` layers' current+queued feed (transient; no history) |
| `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds eight additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/alignment), also without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; TASK 3.17 adds nine additive `Source` text-layer columns (`TextContent`/font family/size/color/bold/italic/underline/horizontal alignment/vertical alignment), also without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
| `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
+2 -1
View File
@@ -354,7 +354,8 @@ public partial class MainViewModel
or nameof(SceneElement.ClipShape) or nameof(SceneElement.IsMirrored)
or nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment);
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
or nameof(Source.TextVerticalAlignment);
if (bakedSurface)
{
var containingScene = Scenes.FirstOrDefault(s => s.Elements.Contains(el));
+131
View File
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Windows.Media;
using ytLive.Models;
using ytLive.Services;
@@ -7,9 +8,138 @@ namespace ytLive.ViewModels;
public partial class MainViewModel
{
private static readonly string[] SystemTextFontFamilies = Fonts.SystemFontFamilies
.Select(font => font.Source)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
private static readonly double[] CommonTextFontSizes =
{ 12, 16, 20, 24, 32, 36, 48, 60, 72, 96, 120, 144, 180, 240, 300 };
private static readonly TextLayerAlignment[] HorizontalAlignmentOptions =
Enum.GetValues<TextLayerAlignment>();
private static readonly TextLayerVerticalAlignment[] VerticalAlignmentOptions =
Enum.GetValues<TextLayerVerticalAlignment>();
private readonly TextOverlayRenderer _textOverlayRenderer = new();
private readonly HashSet<Source> _wiredTextOverlays = new();
private string _selectedTextFontType = "Sans Serif";
public IReadOnlyList<string> TextFontTypeOptions { get; } =
new[] { "Sans Serif", "Serif", "Monospace", "Display", "Other" };
public string SelectedTextFontType
{
get => _selectedTextFontType;
set
{
if (SetProperty(ref _selectedTextFontType, value))
{
OnPropertyChanged(nameof(TextFontFamilies));
OnPropertyChanged(nameof(SelectedTextFontFamily));
}
}
}
public IReadOnlyList<string> TextFontFamilies
{
get
{
var selected = SelectedElement is Source { Type: SourceType.TextOverlay } source
? source.TextFontFamily
: null;
var options = SystemTextFontFamilies
.Where(font => FontTypeFor(font) == SelectedTextFontType)
.ToList();
if (selected != null && FontTypeFor(selected) == SelectedTextFontType
&& !options.Contains(selected, StringComparer.Ordinal))
options.Add(selected);
return options;
}
}
public string? SelectedTextFontFamily
{
get => (SelectedElement as Source)?.TextFontFamily;
set
{
if (SelectedElement is Source { Type: SourceType.TextOverlay } source
&& !string.IsNullOrWhiteSpace(value))
source.TextFontFamily = value;
}
}
public IReadOnlyList<double> TextFontSizeOptions
{
get
{
var selected = (SelectedElement as Source)?.TextFontSize;
return selected is { } size && !CommonTextFontSizes.Contains(size)
? CommonTextFontSizes.Append(size).OrderBy(value => value).ToArray()
: CommonTextFontSizes;
}
}
public IReadOnlyList<TextLayerAlignment> TextHorizontalAlignmentOptions =>
HorizontalAlignmentOptions;
public IReadOnlyList<TextLayerVerticalAlignment> TextVerticalAlignmentOptions =>
VerticalAlignmentOptions;
private void SyncTextFontCategory()
{
SelectedTextFontType = SelectedElement is Source { Type: SourceType.TextOverlay } source
? FontTypeFor(source.TextFontFamily)
: "Sans Serif";
OnPropertyChanged(nameof(SelectedTextFontFamily));
OnPropertyChanged(nameof(TextFontSizeOptions));
}
private static string FontTypeFor(string font)
{
if (font.Contains("Mono", StringComparison.OrdinalIgnoreCase)
|| font.Contains("Consolas", StringComparison.OrdinalIgnoreCase)
|| font.Contains("Courier", StringComparison.OrdinalIgnoreCase)
|| font.Contains("Fixedsys", StringComparison.OrdinalIgnoreCase)
|| font.Contains("Terminal", StringComparison.OrdinalIgnoreCase))
return "Monospace";
if (SerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase)
|| (font.Contains("Serif", StringComparison.OrdinalIgnoreCase)
&& !font.Contains("Sans", StringComparison.OrdinalIgnoreCase)))
return "Serif";
if (font.Contains("Display", StringComparison.OrdinalIgnoreCase)
|| font.Contains("Script", StringComparison.OrdinalIgnoreCase)
|| DisplayFonts.Contains(font, StringComparer.OrdinalIgnoreCase))
return "Display";
if (font.Contains("Sans", StringComparison.OrdinalIgnoreCase)
|| SansSerifFonts.Contains(font, StringComparer.OrdinalIgnoreCase))
return "Sans Serif";
return "Other";
}
private static readonly string[] SerifFonts =
{
"Cambria", "Constantia", "Georgia", "Garamond", "Palatino Linotype", "Times New Roman",
"Book Antiqua", "Century Schoolbook", "Sitka Text", "Sitka Heading", "Bodoni MT", "Bell MT",
};
private static readonly string[] DisplayFonts =
{
"Algerian", "Broadway", "Chiller", "Comic Sans MS", "Cooper Black", "Impact", "Jokerman",
"Magneto", "Papyrus", "Showcard Gothic", "Stencil",
};
private static readonly string[] SansSerifFonts =
{
"Arial", "Arial Nova", "Aptos", "Bahnschrift", "Calibri", "Candara", "Century Gothic",
"Corbel", "Franklin Gothic Medium", "Microsoft Sans Serif", "Segoe UI", "Tahoma",
"Trebuchet MS", "Verdana",
};
private void UpdateConfiguredTextOverlays()
{
var sources = Scenes.SelectMany(scene => scene.Elements).OfType<Source>()
@@ -54,6 +184,7 @@ public partial class MainViewModel
if (propertyName is nameof(Source.TextContent) or nameof(Source.TextFontFamily)
or nameof(Source.TextFontSize) or nameof(Source.TextColor) or nameof(Source.TextBold)
or nameof(Source.TextItalic) or nameof(Source.TextUnderline) or nameof(Source.TextAlignment)
or nameof(Source.TextVerticalAlignment)
or nameof(Source.Width) or nameof(Source.Height))
UpdateTextOverlayPreview(source);
}
+12
View File
@@ -74,6 +74,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(IsBlurSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty));
OnPropertyChanged(nameof(CanToggleClipShape));
SyncTextFontCategory();
if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
}
@@ -113,6 +114,17 @@ public partial class MainViewModel : ViewModelBase
// property. Cheap: the bake is recomputed on demand, once.
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
if (sender is Source text && text.Type == SourceType.TextOverlay)
{
if (e.PropertyName == nameof(Source.TextFontFamily))
{
SelectedTextFontType = FontTypeFor(text.TextFontFamily);
OnPropertyChanged(nameof(SelectedTextFontFamily));
}
if (e.PropertyName == nameof(Source.TextFontSize))
OnPropertyChanged(nameof(TextFontSizeOptions));
}
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
{
// ⛔ Turning the default back ON has to RE-STAMP the shipped art. Browse… writes the
+1 -1
View File
@@ -30,7 +30,7 @@ areas' docs in their own rows keeps this index small to read.
| `SocialsDialogViewModel.cs` | Social Media Site Promotion dialog (six fixed slots, `SocialSlotViewModel`): row 0 = YouTube sign-in gate (sign-in provider fills the channel, delete = sign-out); rows 1-5 validated entries / empty / freemium locks 2-5 (`isPremium` seam); `DetectService` → `ISocialValidator` on confirm (input snaps to text + real logo on success, red do-not + stays editable on failure); `CanSave` blocked while any slot edits/validates/errors; `BarEnabled` switch; commit via `CommittedEntries` + `BuildSocialsConfig()` (YouTube first) + `SaveRequested`/`CancelRequested`. WPF-free — the window owns the dialogs (delete confirm, sign-in gate) and calls `StartEdit`/`ConfirmEdit`/`DeleteSlotAsync`. |
| `MainViewModel.Sources.cs` | Source creation/removal; `AddSource` creates the standalone ticker layer with one-per-scene gating and its 1920×48 default geometry; TextOverlay starts as a visible 900×140 draggable text layer. |
| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; output resolution reads its immutable cached frame. |
| `MainViewModel.TextOverlay.cs` | Raster-on-change TextOverlay preview hookup and settings/dimension invalidation; exposes font categories and selected-layer font/size options; output resolution reads its immutable cached frame. |
| `MainViewModel.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
+11
View File
@@ -18,6 +18,9 @@ public class SourceTextSettingsTests
Assert.False(source.TextItalic);
Assert.False(source.TextUnderline);
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment);
Assert.Equal("#FFFFFF", source.TextColorEntry.Hex);
Assert.Contains(source.TextColorOptions, color => color.Hex == "#FFFFFF");
}
[Fact]
@@ -29,12 +32,14 @@ public class SourceTextSettingsTests
TextFontSize = double.NaN,
TextColor = "not-a-color",
TextAlignment = (TextLayerAlignment)999,
TextVerticalAlignment = (TextLayerVerticalAlignment)999,
};
Assert.Equal("Segoe UI", source.TextFontFamily);
Assert.Equal(48, source.TextFontSize);
Assert.Equal("#FFFFFF", source.TextColor);
Assert.Equal(TextLayerAlignment.Left, source.TextAlignment);
Assert.Equal(TextLayerVerticalAlignment.Top, source.TextVerticalAlignment);
source.TextFontSize = 1;
Assert.Equal(8, source.TextFontSize);
@@ -42,5 +47,11 @@ public class SourceTextSettingsTests
Assert.Equal(300, source.TextFontSize);
source.TextColor = "#abcdef";
Assert.Equal("#ABCDEF", source.TextColor);
source.TextColorEntry = BorderColorPalette.All.Single(color => color.Hex == "#FF0000");
Assert.Equal("#FF0000", source.TextColor);
Assert.Equal("Red", source.TextColorEntry.Name);
source.TextColor = "#123456";
Assert.Contains(source.TextColorOptions, color => color.Hex == "#123456");
}
}
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class TextLayerDropdownTests
{
private readonly RealAppHost _app;
public TextLayerDropdownTests(RealAppHost app) => _app = app;
[Fact]
public void Text_Controls_Show_Defaults_Update_And_Restore_Selected_Values()
{
_app.Run(Run);
}
private static void Run()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-controls-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = dbPath;
MainWindow? window = null;
try
{
window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
vm.EditingActive = true;
vm.StagedScene = vm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live));
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var source = vm.StagedScene!.Elements.OfType<Source>()
.Single(item => item.Type == SourceType.TextOverlay);
vm.SelectedElement = source;
window.Show();
window.UpdateLayout();
var panel = (LeftPanel)window.FindName("LeftPanel")!;
var fontType = (ComboBox)panel.FindName("TextLayerFontTypePicker")!;
var font = (ComboBox)panel.FindName("TextLayerFontPicker")!;
var size = (ComboBox)panel.FindName("TextLayerSizePicker")!;
var colorField = (TextBox)panel.FindName("TextLayerColorEditor")!;
var color = (ComboBox)panel.FindName("TextLayerColorPicker")!;
var horizontal = (ComboBox)panel.FindName("TextLayerHorizontalAlignmentPicker")!;
var vertical = (ComboBox)panel.FindName("TextLayerVerticalAlignmentPicker")!;
Assert.Equal("Sans Serif", fontType.SelectedItem);
Assert.Equal("Sans Serif", fontType.Text);
Assert.Equal("Segoe UI", font.SelectedItem);
Assert.Equal("Segoe UI", font.Text);
Assert.Equal(48d, size.SelectedItem);
Assert.Equal("48", size.Text);
Assert.Equal("#FFFFFF", colorField.Text);
Assert.Equal(source.TextColorEntry, color.SelectedItem);
Assert.Equal(TextLayerAlignment.Left, horizontal.SelectedItem);
Assert.Equal("Left", horizontal.Text);
Assert.Equal(TextLayerVerticalAlignment.Top, vertical.SelectedItem);
Assert.Equal("Top", vertical.Text);
Assert.Same(Application.Current.Resources["YtComboBox"], color.Style);
Assert.Same(panel.Resources["BorderColorTemplates"], color.ItemTemplateSelector);
Assert.Equal(10, ((FrameworkElement)fontType.Parent).Margin.Left);
Assert.Equal(10, ((FrameworkElement)color.Parent).Margin.Left);
Assert.Equal(10, ((FrameworkElement)horizontal.Parent).Margin.Left);
Assert.Equal(10, ((FrameworkElement)vertical.Parent).Margin.Left);
fontType.SelectedItem = "Serif";
window.UpdateLayout();
Assert.Equal("Serif", vm.SelectedTextFontType);
Assert.Equal("Segoe UI", source.TextFontFamily);
Assert.NotEmpty(font.Items);
var serifFont = font.Items.Cast<string>().First();
font.SelectedItem = serifFont;
size.SelectedItem = 36d;
color.SelectedItem = source.TextColorOptions.Single(item => item.Hex == "#FF0000");
horizontal.SelectedItem = TextLayerAlignment.Center;
vertical.SelectedItem = TextLayerVerticalAlignment.Bottom;
window.UpdateLayout();
Assert.Equal(serifFont, source.TextFontFamily);
Assert.Equal(36, source.TextFontSize);
Assert.Equal("#FF0000", source.TextColor);
Assert.Equal(TextLayerAlignment.Center, source.TextAlignment);
Assert.Equal(TextLayerVerticalAlignment.Bottom, source.TextVerticalAlignment);
var raster = Assert.IsType<ytLive.Services.VideoFrame>(vm.ResolveOutputFrame(source));
Assert.True(FirstInkRow(raster) > 50,
"bottom alignment did not place the text lower inside its box");
Assert.Equal("Serif", fontType.SelectedItem);
Assert.Equal(serifFont, font.SelectedItem);
Assert.Equal(serifFont, font.Text);
Assert.Equal(36d, size.SelectedItem);
Assert.Equal("36", size.Text);
Assert.Equal("#FF0000", colorField.Text);
Assert.Equal(source.TextColorEntry, color.SelectedItem);
Assert.Equal(TextLayerAlignment.Center, horizontal.SelectedItem);
Assert.Equal("Center", horizontal.Text);
Assert.Equal(TextLayerVerticalAlignment.Bottom, vertical.SelectedItem);
Assert.Equal("Bottom", vertical.Text);
vm.SaveLayoutNow();
window.Close();
window = null;
SqliteConnection.ClearAllPools();
window = new MainWindow();
var reloadedVm = (MainViewModel)window.DataContext;
reloadedVm.EditingActive = true;
reloadedVm.StagedScene = reloadedVm.Scenes.Single(scene => SceneCatalog.Is(scene.Name, SceneCatalog.Live));
var reloaded = reloadedVm.StagedScene.Elements.OfType<Source>()
.Single(item => item.Type == SourceType.TextOverlay);
reloadedVm.SelectedElement = reloaded;
window.Show();
window.UpdateLayout();
var reloadedPanel = (LeftPanel)window.FindName("LeftPanel")!;
Assert.Equal(reloadedVm.SelectedTextFontType, ((ComboBox)reloadedPanel.FindName("TextLayerFontTypePicker")!).SelectedItem);
Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).SelectedItem);
Assert.Equal(reloaded.TextFontFamily, ((ComboBox)reloadedPanel.FindName("TextLayerFontPicker")!).Text);
Assert.Equal(reloaded.TextFontSize, ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).SelectedItem);
Assert.Equal(reloaded.TextFontSize.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerSizePicker")!).Text);
Assert.Equal(reloaded.TextColor, ((TextBox)reloadedPanel.FindName("TextLayerColorEditor")!).Text);
Assert.Equal(reloaded.TextColorEntry, ((ComboBox)reloadedPanel.FindName("TextLayerColorPicker")!).SelectedItem);
Assert.Equal(reloaded.TextAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).SelectedItem);
Assert.Equal(reloaded.TextAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerHorizontalAlignmentPicker")!).Text);
Assert.Equal(reloaded.TextVerticalAlignment, ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).SelectedItem);
Assert.Equal(reloaded.TextVerticalAlignment.ToString(), ((ComboBox)reloadedPanel.FindName("TextLayerVerticalAlignmentPicker")!).Text);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(dbPath); } catch { /* best-effort cleanup */ }
}
}
private static int FirstInkRow(ytLive.Services.VideoFrame frame)
{
for (var y = 0; y < frame.Height; y++)
for (var x = 0; x < frame.Width; x++)
if (frame.BgraPixels[(y * frame.Width + x) * 4 + 3] > 0)
return y;
return frame.Height;
}
}