feat(text): add static text editor and preview

TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
This commit is contained in:
2026-11-04 14:28:47 -08:00
parent 60c4a0032f
commit 58eb640c4e
7 changed files with 210 additions and 10 deletions
+63 -1
View File
@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers" xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:models="clr-namespace:ytLive.Models"> xmlns:models="clr-namespace:ytLive.Models"
xmlns:media="clr-namespace:System.Windows.Media;assembly=PresentationCore">
<UserControl.Resources> <UserControl.Resources>
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the <!-- 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 colour entries are drawn by TEMPLATES, in the theme's colours and with the
@@ -317,6 +318,67 @@
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent" <ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"> Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0"> <StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
<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"/>
<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"
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=PropertyChanged}"
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=PropertyChanged}"
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"/>
<TextBox x:Name="TextLayerColorEditor"
Text="{Binding SelectedElement.TextColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
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"/>
</DockPanel>
<WrapPanel Margin="0,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"
IsChecked="{Binding SelectedElement.TextItalic, Mode=TwoWay}"/>
<CheckBox Style="{StaticResource PropertyPillCheck}" Content="Underline" Margin="0,0,8,4"
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>
</StackPanel>
<!-- Opacity + mirror/shape (draggable elements: image, webcam) --> <!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8"> <StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}"> <StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
+37 -1
View File
@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers" xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:local="clr-namespace:ytLive" xmlns:controls="clr-namespace:ytLive.Controls"> xmlns:local="clr-namespace:ytLive" xmlns:controls="clr-namespace:ytLive.Controls"
xmlns:models="clr-namespace:ytLive.Models">
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="6" Margin="8,0" <Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="6" Margin="8,0"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}"> BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid> <Grid>
@@ -175,6 +176,41 @@
</Style> </Style>
</Image.Style> </Image.Style>
</Image> </Image>
<TextBlock Tag="TextLayerPreview" Text="{Binding TextContent}"
FontFamily="{Binding TextFontFamily}"
FontSize="{Binding TextFontSize}"
Foreground="{Binding TextColor, Converter={StaticResource HexToBrush}}"
TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
IsHitTestVisible="False">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Setter Property="TextAlignment" Value="Left"/>
<Setter Property="FontWeight" Value="Normal"/>
<Setter Property="FontStyle" Value="Normal"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextBold}" Value="True">
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextItalic}" Value="True">
<Setter Property="FontStyle" Value="Italic"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextUnderline}" Value="True">
<Setter Property="TextDecorations" Value="{x:Static TextDecorations.Underline}"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextAlignment}" Value="{x:Static models:TextLayerAlignment.Center}">
<Setter Property="TextAlignment" Value="Center"/>
</DataTrigger>
<DataTrigger Binding="{Binding TextAlignment}" Value="{x:Static models:TextLayerAlignment.Right}">
<Setter Property="TextAlignment" Value="Right"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Viewbox Stretch="Uniform"> <Viewbox Stretch="Uniform">
<Grid Width="1" Height="1"> <Grid Width="1" Height="1">
<!-- Round webcam: a plain Image (the same efficient WriteableBitmap <!-- Round webcam: a plain Image (the same efficient WriteableBitmap
+2 -2
View File
@@ -10,9 +10,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign | | `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here | | `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
| `TopBar.xaml(.cs)` | Row 0 session surface: 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` | | `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`, 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. | | `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay `TextBlock` preview, 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 | | `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard | | `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 |
| `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 | | `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 | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above | | `LeftPanel.xaml.cs` | Code-behind for the panel above |
+1 -1
View File
@@ -111,7 +111,7 @@ public class Source : SceneElement
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle /// and sizes it in the preview, so it needs the same selection box, grab and resize handle
/// (creator ruling 2026-11-01).</summary> /// (creator ruling 2026-11-01).</summary>
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker; or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary> /// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
+8 -4
View File
@@ -62,22 +62,26 @@ public partial class MainViewModel
? $"{baseName}-{webCount}" ? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName), : NextSourceName(scene, baseName),
Type = sourceType, Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 X = sourceType == SourceType.TextOverlay ? 160
: sourceType == SourceType.ChatBox ? 1400
: sourceType == SourceType.AlertBox ? 620 : sourceType == SourceType.AlertBox ? 620
: sourceType == SourceType.Blur ? 700 : 0, : sourceType == SourceType.Blur ? 700 : 0,
// The scoreboard is a full-width bar: its Y comes from the edge it owns (resolved // The scoreboard is a full-width bar: its Y comes from the edge it owns (resolved
// against the social bar at render time), never from a drag. // against the social bar at render time), never from a drag.
Y = sourceType == SourceType.Scoreboard ? 1024 Y = sourceType == SourceType.TextOverlay ? 120
: sourceType == SourceType.Scoreboard ? 1024
: sourceType == SourceType.ChatBox ? 20 : sourceType == SourceType.ChatBox ? 20
: sourceType == SourceType.AlertBox ? 430 : sourceType == SourceType.AlertBox ? 430
: sourceType == SourceType.Blur ? 600 : 0, : sourceType == SourceType.Blur ? 600 : 0,
Width = sourceType == SourceType.WebSource ? 640 Width = sourceType == SourceType.TextOverlay ? 900
: sourceType == SourceType.WebSource ? 640
: sourceType == SourceType.ChatBox ? 500 : sourceType == SourceType.ChatBox ? 500
: sourceType == SourceType.AlertBox ? 680 : sourceType == SourceType.AlertBox ? 680
: sourceType == SourceType.Scoreboard ? 1920 : sourceType == SourceType.Scoreboard ? 1920
: sourceType == SourceType.Ticker ? 1920 : sourceType == SourceType.Ticker ? 1920
: sourceType == SourceType.Blur ? 240 : 0, : sourceType == SourceType.Blur ? 240 : 0,
Height = sourceType == SourceType.WebSource ? 480 Height = sourceType == SourceType.TextOverlay ? 140
: sourceType == SourceType.WebSource ? 480
: sourceType == SourceType.ChatBox ? 648 : sourceType == SourceType.ChatBox ? 648
: sourceType == SourceType.AlertBox ? 200 : sourceType == SourceType.AlertBox ? 200
: sourceType == SourceType.Scoreboard ? 56 : sourceType == SourceType.Scoreboard ? 56
+1 -1
View File
@@ -29,7 +29,7 @@ areas' docs in their own rows keeps this index small to read.
| `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` | | `MicPickerViewModel.cs` | Select Microphone dialog (voice source): async mic list (loading / has / none states), `UseRequested(MicrophoneDeviceInfo)`/`CancelRequested` |
| `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`. | | `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. | | `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.AlertTicker.cs` | AlertBox ticker preview routing: selects the preview scene's AlertBox settings and publishes the positioned box-sized frame to PreviewPane. | | `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) Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
@@ -0,0 +1,98 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Controls;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
[Collection("RealApp")]
public sealed class TextLayerEditorPreviewTests
{
private readonly RealAppHost _app;
public TextLayerEditorPreviewTests(RealAppHost app) => _app = app;
[Fact]
public void Editing_One_Text_Layer_Updates_Only_That_Preview()
{
_app.Run(Run);
}
private static void Run()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-editor-{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);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var textLayers = vm.StagedScene!.Elements.OfType<Source>()
.Where(source => source.Type == SourceType.TextOverlay).ToArray();
Assert.Equal(2, textLayers.Length);
var first = textLayers[0];
var second = textLayers[1];
second.TextContent = "Leave this caption alone";
vm.SelectedElement = first;
window.Show();
window.UpdateLayout();
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
var editor = (TextBox)leftPanel.FindName("TextLayerEditor")!;
Assert.Equal("Text", editor.Text);
Assert.True(first.IsDraggable);
Assert.Equal(900, first.Width);
Assert.Equal(140, first.Height);
editor.Text = "Updated headline\nSecond line";
editor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
window.UpdateLayout();
Assert.Equal("Updated headline\nSecond line", first.TextContent);
Assert.Equal("Leave this caption alone", second.TextContent);
var preview = (PreviewPane)window.FindName("PreviewPane")!;
var firstPreview = FindTextPreview(preview, first);
var secondPreview = FindTextPreview(preview, second);
Assert.NotNull(firstPreview);
Assert.NotNull(secondPreview);
Assert.Equal("Updated headline\nSecond line", firstPreview!.Text);
Assert.Equal("Leave this caption alone", secondPreview!.Text);
Assert.True(firstPreview.IsVisible);
Assert.True(secondPreview.IsVisible);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(dbPath); } catch { /* best-effort cleanup */ }
}
}
private static TextBlock? FindTextPreview(DependencyObject parent, Source source)
{
if (parent is TextBlock text && Equals(text.Tag, "TextLayerPreview")
&& ReferenceEquals(text.DataContext, source))
return text;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var result = FindTextPreview(VisualTreeHelper.GetChild(parent, i), source);
if (result != null) return result;
}
return null;
}
}