Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 10 additions & 36 deletions DesktopClock/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,42 +113,16 @@
<Viewbox Height="{Binding Height, Source={x:Static p:Settings.Default}, Mode=OneWay}">
<Border CornerRadius="{Binding BackgroundCornerRadius, Source={x:Static p:Settings.Default}, Mode=OneWay}"
Padding="1,0,1,0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent" />

<Style.Triggers>
<DataTrigger Binding="{Binding BackgroundEnabled, Source={x:Static p:Settings.Default}, Mode=OneWay}"
Value="True">
<DataTrigger.Setters>
<Setter Property="Background">
<Setter.Value>
<ImageBrush Opacity="{Binding BackgroundOpacity, Source={x:Static p:Settings.Default}, Mode=OneWay}"
ImageSource="{Binding BackgroundImagePath, Source={x:Static p:Settings.Default}, Mode=OneWay}"
Stretch="{Binding BackgroundImageStretch, Source={x:Static p:Settings.Default}, Mode=OneWay}" />
</Setter.Value>
</Setter>
</DataTrigger.Setters>
</DataTrigger>

<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding BackgroundEnabled, Source={x:Static p:Settings.Default}, Mode=OneWay}" Value="True" />
<Condition Binding="{Binding BackgroundImagePath, Source={x:Static p:Settings.Default}, Mode=OneWay}" Value="" />
</MultiDataTrigger.Conditions>

<MultiDataTrigger.Setters>
<Setter Property="Background">
<Setter.Value>
<SolidColorBrush Opacity="{Binding BackgroundOpacity, Source={x:Static p:Settings.Default}, Mode=OneWay}"
Color="{Binding OuterColor, Source={x:Static p:Settings.Default}, Mode=OneWay}" />
</Setter.Value>
</Setter>
</MultiDataTrigger.Setters>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Border.Background>
<!-- Falls back to the solid outer color when the background image is missing or unreadable, so the clock is never left with an invisible background. -->
<MultiBinding Converter="{local:BackgroundBrushConverter}">
<Binding Path="BackgroundEnabled" Source="{x:Static p:Settings.Default}" Mode="OneWay" />
<Binding Path="BackgroundImagePath" Source="{x:Static p:Settings.Default}" Mode="OneWay" />
<Binding Path="OuterColor" Source="{x:Static p:Settings.Default}" Mode="OneWay" />
<Binding Path="BackgroundOpacity" Source="{x:Static p:Settings.Default}" Mode="OneWay" />
<Binding Path="BackgroundImageStretch" Source="{x:Static p:Settings.Default}" Mode="OneWay" />
</MultiBinding>
</Border.Background>

<local:OutlinedTextBlock Text="{Binding CurrentTimeOrCountdownString}"
StrokeThickness="{Binding OutlineThickness, Source={x:Static p:Settings.Default}, Mode=OneWay}"
Expand Down
65 changes: 65 additions & 0 deletions DesktopClock/Utilities/BackgroundBrushConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace DesktopClock;

/// <summary>
/// Builds the clock's background brush from the current settings, falling back to the solid outer color when a chosen background image is missing or can't be read.
/// </summary>
/// <remarks>
/// Bound values, in order: BackgroundEnabled, BackgroundImagePath, OuterColor, BackgroundOpacity, BackgroundImageStretch.
/// </remarks>
public class BackgroundBrushConverter : MarkupExtension, IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
var enabled = values.Length > 0 && values[0] is bool b && b;

// Background off means outlined text on a transparent window.
if (!enabled)
return Brushes.Transparent;

var path = values.Length > 1 ? values[1] as string : null;
var outerColor = values.Length > 2 && values[2] is Color color ? color : Colors.Transparent;
var opacity = values.Length > 3 && values[3] is double o ? o : 1d;
var stretch = values.Length > 4 && values[4] is Stretch s ? s : Stretch.Fill;

// Use the image when it loads; otherwise fall back to the solid color so the clock never ends up with an invisible background.
var image = TryLoadImage(path);
if (image != null)
return new ImageBrush(image) { Opacity = opacity, Stretch = stretch };

return new SolidColorBrush(outerColor) { Opacity = opacity };
}

public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) =>
throw new NotSupportedException();

public override object ProvideValue(IServiceProvider serviceProvider) => this;

private static ImageSource TryLoadImage(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;

try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad; // Decode now so a missing or invalid file fails here, and don't hold the file locked.
bitmap.UriSource = new Uri(path, UriKind.Absolute);
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
catch
{
// Missing file, non-image content, unavailable share, or a malformed path all fall back to the solid color.
return null;
}
Comment on lines +59 to +63

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the bare catch here, deliberately:

  • It matches the intent exactly. The contract is "if the image can't be produced for any reason, fall back to the solid color" — the same all-or-nothing semantics the previous XAML binding had (WPF's binding engine swallowed every load failure silently). Enumerating exception types just risks missing one (UriFormatException, FileNotFoundException, NotSupportedException, IOException, UnauthorizedAccessException, ArgumentException are all reachable from this block) and turning a bad path into a crash on someone's machine.
  • It's the established pattern in this codebase — e.g. Tokenizer.FormatWithTokenizerOrFallBack, Settings.Save/LoadFromFile/FileChanged, MainWindow.CopyToClipboard/TryPlaySound, ThemeManager.ApplyTitleBarTheme all use bare catch for graceful fallback. A critical-exception filter here would be the only one of its kind.
  • The critical-exception concern is mostly theoretical on net481: ThreadAbortException automatically re-throws at the end of a catch block, and a process-fatal OOM won't be meaningfully rescued by one converter propagating it.

If the project ever adopts a repo-wide exception-filter convention this spot should follow it, but I don't think this PR is the place to introduce a one-off.

}
}