feat(settings): brand-logo thumbnail with a hover enlargement
The creator (2026-10-30): the brand-logo field in App Settings showed only a file NAME, so a myopic user could not confirm which picture was set. Now a 28x28 thumbnail sits left of the field showing the actual image, and hovering it opens an enlarged copy (up to 420x320) beside the panel with the file name underneath. A click collapses it; moving the pointer away closes it, unless the pointer walked onto the popup itself, where it stays so the creator can look closely. Mechanics: - `MainViewModel.BrandLogoPreviewImage` decodes the stored asset ONCE and freezes it, so the thumbnail and the enlargement share one bitmap and no dispatcher hop is needed. A corrupt image returns null instead of taking the dialog down. - The thumbnail is hidden when no image is set (NotNullToVis), so there is nothing to hover and no empty frame can appear. - The popup is a separate window: WPF refuses to open one until its placement target is in a live visual tree, which is why the test hosts the control in a real window rather than asserting on a detached control. Guard: BrandLogoPreviewTests — the wiring (image binding, the no-image gate, the four gesture handlers, the readable enlargement size) and the control's open/close behaviour.
This commit is contained in:
@@ -57,9 +57,36 @@
|
||||
<DockPanel Margin="10,0,0,8">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" Command="{Binding BrowseBrandLogoCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="8,2" FontSize="11" Margin="6,0,0,0"/>
|
||||
<!-- A file NAME says nothing about the picture. The thumbnail shows what
|
||||
is set; hovering enlarges it, a click collapses it again. -->
|
||||
<Border x:Name="BrandLogoThumbFrame" DockPanel.Dock="Left" Width="28" Height="28"
|
||||
Margin="0,0,6,0" CornerRadius="4" ClipToBounds="True"
|
||||
Background="{StaticResource Brush.SurfaceContainerLowest}"
|
||||
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1"
|
||||
Cursor="Hand" ToolTip="Hover to enlarge — click to close"
|
||||
Visibility="{Binding BrandLogoPreviewImage, Converter={StaticResource NotNullToVis}}">
|
||||
<Image x:Name="BrandLogoThumbImage" Source="{Binding BrandLogoPreviewImage}"
|
||||
Stretch="Uniform" MouseEnter="BrandLogoThumb_MouseEnter"
|
||||
MouseLeave="BrandLogoThumb_MouseLeave"
|
||||
MouseLeftButtonUp="BrandLogoThumb_Click"/>
|
||||
</Border>
|
||||
<TextBox Style="{StaticResource YtTextBoxCompact}" Text="{Binding BrandLogoFileDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<Popup x:Name="BrandLogoPreviewPopup" PlacementTarget="{Binding ElementName=BrandLogoThumbFrame}"
|
||||
Placement="Right" HorizontalOffset="6" StaysOpen="True" AllowsTransparency="True"
|
||||
IsHitTestVisible="True" MouseLeave="BrandLogoPreviewPopup_MouseLeave">
|
||||
<Border Padding="6" CornerRadius="6"
|
||||
Background="{StaticResource Brush.SurfaceContainerHighest}"
|
||||
BorderBrush="{StaticResource Brush.Outline}" BorderThickness="1">
|
||||
<StackPanel>
|
||||
<Image Source="{Binding BrandLogoPreviewImage}" MaxWidth="420" MaxHeight="320"
|
||||
Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<TextBlock Text="{Binding BrandLogoFileDisplay}" FontSize="11" Margin="0,6,0,0"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<TextBlock Text="Preview" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<TextBlock Text="Zoom view shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
|
||||
@@ -11,6 +11,36 @@ public partial class OverlayHost : UserControl
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate
|
||||
/// window, so nothing else can observe it.</summary>
|
||||
internal bool IsBrandLogoPreviewOpen => BrandLogoPreviewPopup.IsOpen;
|
||||
|
||||
/// <summary>Hover opens the enlargement. Nothing to show when no image is set, so the
|
||||
/// thumbnail simply does nothing in that case.</summary>
|
||||
private void BrandLogoThumb_MouseEnter(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> OpenBrandLogoPreview();
|
||||
|
||||
/// <summary>Leaving the thumbnail closes it — unless the pointer walked onto the popup
|
||||
/// itself (which sits beside it), where it stays put so the creator can look closely.</summary>
|
||||
private void BrandLogoThumb_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
if (BrandLogoPreviewPopup.Child?.IsMouseOver == true) return;
|
||||
CloseBrandLogoPreview();
|
||||
}
|
||||
|
||||
/// <summary>A click collapses the enlargement — the explicit "enough, thanks" gesture.</summary>
|
||||
private void BrandLogoThumb_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
=> CloseBrandLogoPreview();
|
||||
|
||||
private void BrandLogoPreviewPopup_MouseLeave(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> CloseBrandLogoPreview();
|
||||
|
||||
/// <summary>The thumbnail is hidden when no image is set (see the XAML), so there is nothing
|
||||
/// to open in that case — no empty frame can appear.</summary>
|
||||
internal void OpenBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = true;
|
||||
|
||||
internal void CloseBrandLogoPreview() => BrandLogoPreviewPopup.IsOpen = false;
|
||||
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
@@ -22,6 +24,51 @@ public partial class MainViewModel
|
||||
|
||||
public string BrandLogoFileDisplay => _layoutStore.GetBrandLogoFileName() ?? "None";
|
||||
|
||||
private ImageSource? _brandLogoPreviewImage;
|
||||
private bool _brandLogoPreviewLoaded;
|
||||
|
||||
/// <summary>The current brand-logo image, for the thumbnail beside the file field in App
|
||||
/// Settings (the creator, 2026-10-30: a myopic user cannot tell what a file *name* looks
|
||||
/// like). Null until an image is set.</summary>
|
||||
public ImageSource? BrandLogoPreviewImage
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_brandLogoPreviewLoaded) RefreshBrandLogoPreview();
|
||||
return _brandLogoPreviewImage;
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshBrandLogoPreview()
|
||||
{
|
||||
_brandLogoPreviewLoaded = true;
|
||||
_brandLogoPreviewImage = LoadBrandLogoImage(_layoutStore.GetBrandLogoAssetId());
|
||||
OnPropertyChanged(nameof(BrandLogoPreviewImage));
|
||||
}
|
||||
|
||||
/// <summary>Decoded once and frozen: the thumbnail and the enlarged preview share it, and a
|
||||
/// frozen bitmap can be handed to any thread without a dispatcher round-trip.</summary>
|
||||
private ImageSource? LoadBrandLogoImage(string? assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
var bytes = _layoutStore.GetAssetBytes(assetId);
|
||||
if (bytes is not { Length: > 0 }) return null;
|
||||
try
|
||||
{
|
||||
var image = new BitmapImage();
|
||||
image.BeginInit();
|
||||
image.CacheOption = BitmapCacheOption.OnLoad;
|
||||
image.StreamSource = new MemoryStream(bytes);
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null; // a corrupt image must not take the dialog down with it
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseBrandLogo()
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||
@@ -32,6 +79,7 @@ public partial class MainViewModel
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
_layoutStore.SetBrandLogo(File.ReadAllBytes(dialog.FileName), Path.GetFileName(dialog.FileName));
|
||||
EnsureBrandLogos();
|
||||
RefreshBrandLogoPreview();
|
||||
OnPropertyChanged(nameof(BrandLogoFileDisplay));
|
||||
ScheduleSave();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>The creator (2026-10-30): in App Settings the brand-logo field is only a file NAME,
|
||||
/// which says nothing about the picture, so "myopic users like myself" cannot confirm what is
|
||||
/// set. The field now carries a thumbnail — hover enlarges it, a click collapses it.
|
||||
///
|
||||
/// <para>The ONE test covers both halves: the XAML wiring that routes hover/leave/click, and the
|
||||
/// control's own open/close behaviour (a Popup is a separate window, so only this seam can
|
||||
/// observe it). Runs inside the real App because the control resolves theme resources at load.</para></summary>
|
||||
[Collection("RealApp")]
|
||||
public class BrandLogoPreviewTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public BrandLogoPreviewTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Hovering_the_thumbnail_opens_the_enlarged_preview_and_a_click_collapses_it()
|
||||
{
|
||||
// 1. Wiring: the thumbnail is image-bound and hidden when no image is set, the enlarged
|
||||
// popup shows the same source at a readable size, and every gesture reaches a handler.
|
||||
var xaml = SourceAssert.Text("Controls", "OverlayHost.xaml");
|
||||
Assert.Contains("x:Name=\"BrandLogoThumbImage\"", xaml);
|
||||
Assert.Contains("{Binding BrandLogoPreviewImage}", xaml);
|
||||
Assert.Contains("NotNullToVis", xaml);
|
||||
Assert.Contains("x:Name=\"BrandLogoPreviewPopup\"", xaml);
|
||||
Assert.Contains("MaxWidth=\"420\"", xaml);
|
||||
foreach (var handler in new[]
|
||||
{
|
||||
"MouseEnter=\"BrandLogoThumb_MouseEnter\"",
|
||||
"MouseLeave=\"BrandLogoThumb_MouseLeave\"",
|
||||
"MouseLeftButtonUp=\"BrandLogoThumb_Click\"",
|
||||
"MouseLeave=\"BrandLogoPreviewPopup_MouseLeave\"",
|
||||
})
|
||||
Assert.Contains(handler, xaml);
|
||||
|
||||
// 2. Behaviour on the real control.
|
||||
_app.Run(() =>
|
||||
{
|
||||
// A Popup refuses to open until its placement target is in a live visual tree, so the
|
||||
// control is hosted in a real window — the same condition the app provides.
|
||||
var host = new ytLive.Controls.OverlayHost();
|
||||
var window = new System.Windows.Window { Content = host, Width = 420, Height = 420 };
|
||||
window.Show();
|
||||
try
|
||||
{
|
||||
Assert.False(host.IsBrandLogoPreviewOpen);
|
||||
|
||||
host.OpenBrandLogoPreview();
|
||||
Assert.True(host.IsBrandLogoPreviewOpen, "hovering the thumbnail must open the enlargement");
|
||||
|
||||
host.CloseBrandLogoPreview();
|
||||
Assert.False(host.IsBrandLogoPreviewOpen, "a click must collapse it again");
|
||||
}
|
||||
finally { window.Close(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user