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:x="http://schemas.microsoft.com/winfx/2006/xaml"
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>
<!-- 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
@@ -317,6 +318,67 @@
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
<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) -->
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
<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:x="http://schemas.microsoft.com/winfx/2006/xaml"
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"
BorderBrush="{Binding PreviewGlowBrush}" BorderThickness="{Binding PreviewGlowThickness}">
<Grid>
@@ -175,6 +176,41 @@
</Style>
</Image.Style>
</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">
<Grid Width="1" Height="1">
<!-- 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 |
| `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` |
| `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 |
| `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 |
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip 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
/// (creator ruling 2026-11-01).</summary>
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>
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}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400
X = sourceType == SourceType.TextOverlay ? 160
: sourceType == SourceType.ChatBox ? 1400
: sourceType == SourceType.AlertBox ? 620
: sourceType == SourceType.Blur ? 700 : 0,
// 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.
Y = sourceType == SourceType.Scoreboard ? 1024
Y = sourceType == SourceType.TextOverlay ? 120
: sourceType == SourceType.Scoreboard ? 1024
: sourceType == SourceType.ChatBox ? 20
: sourceType == SourceType.AlertBox ? 430
: 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.AlertBox ? 680
: sourceType == SourceType.Scoreboard ? 1920
: sourceType == SourceType.Ticker ? 1920
: 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.AlertBox ? 200
: 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` |
| `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. |
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;
}
}