Friday, August 9, 2024

FODY

The Problem

I spent some time looking at ways to reduce all the repetitive property declarations required for MVVM. So have a lot of other people, most of whom are far smarter than me.

The Community Toolkit looked very promising.


But even though it claims to work in VB and .Net Framework it does not. No errors, no warnings, no binding errors, modifying properties in controls does not update the bound properties. So that's a shame.

The good news

One of my colleagues recommend FODY - a GitHub project that claims to work for VB in .Net Framework. OK - I'm game. I put together the most trivial project I could think of. We have our ViewModels in our code-behind, we use VB, and we're still on .Net Framework. Yes, we're practically Neanderthal.


FODY is much bigger than simply providing MVVM functionality. It's a framework for all sorts of things. The specific package I need is called PropertyChanged.Fody. Let's get started then.

In Visual Studio 2022 create a WPF, VB, .NetFramework project called FODY


In the menu select Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution...

Click the Browse tab and search for FODY


Install Fody and PropertyChanged.Fody into your solution. Your packages.config will look something like this.

<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="Fody" version="6.8.1" targetFramework="net472" developmentDependency="true" />
  <package id="PropertyChanged.Fody" version="4.1.0" targetFramework="net472" />
</packages>


Now we will write some standard XAML with a text box and a text block. Whatever you type in the text box will appear in the text block thanks to some MVVM bindings. Fody does not require any changes to your XAML.

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:FODY"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="FODY Test" Height="450" Width="800">
    <StackPanel Orientation="Vertical" HorizontalAlignment="Left">
        <TextBox Text="{Binding theText, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
        <TextBlock Text="{Binding theText}"/>
    </StackPanel>
</Window>

Without clever add-ins the view model would look something like this. Which requires a lot of typing.

Imports System.ComponentModel
Imports System.Runtime.CompilerServices

Class MainWindow
    Implements INotifyPropertyChanged

    Private _theText As String = ""
    Public Property theText As String
        Get
            Return _theText
        End Get
        Set(value As String)
            SetProperty(_theText, value)
        End Set
    End Property

    Public Event PropertyChanged As System.ComponentModel.PropertyChangedEventHandler Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged

    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function

    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
    End Sub
End Class

The end result predictably looks like this

But with Fody we can replace these 30 lines of code with something far more succinct.

Imports PropertyChanged
<AddINotifyPropertyChangedInterface>
Class MainWindow
    Public Property theText As String = ""
End Class

Which gives us exactly the same result.

So what's the bad news?

A common technique for investigating binding issues is to put break points on property getters and setters to make sure they are being called when expected. Setter not called when the user modifies a control - check UpdateSourceTrigger. Getter not called after the setter is called - control is not bound to the property. When we don't have explicit setters and getters, we can't use these techniques.

So maybe all that repetitive code has value after all.

Monday, July 29, 2024

RowDefinition with Height="*" not honored inside ScrollViewer, bug?

I came across a problem with an Infragistics XamDataGrid inside a grid inside a ScrollViewer. The problem occurs when I populate the data grid with more data than will fit. Instead of the data grid scrolling, the data grid expands vertically beyond the height of the row it is in. Take a look at this XAML and code.

<Window x:Class="GridHeight.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:GridHeight"
        xmlns:igDP="http://infragistics.com/DataPresenter"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <RoutedCommand x:Key="PopulateCommand"/>
        <local:BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource PopulateCommand}" Executed="Populate_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Border Grid.Row="0">
            <Expander Header="Expand" IsExpanded="{Binding IsExpanded}"/>
        </Border>
        <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="auto"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="auto"/>
                </Grid.RowDefinitions>
                <Border Grid.Row="0" Visibility="{Binding IsExpanded, Converter={StaticResource BooleanToVisibilityConverter }}">
                    <TextBlock Text="Expanded" Padding="20"/>
                </Border>
                <igDP:XamDataGrid Grid.Row="1" DataSource="{Binding Items}" ScrollingMode="Immediate">
                    <igDP:XamDataGrid.FieldLayouts>
                        <igDP:FieldLayout>
                            <igDP:FieldLayout.Fields>
                                <igDP:TextField Label="Item" Name="Item"/>
                            </igDP:FieldLayout.Fields>
                        </igDP:FieldLayout>
                    </igDP:XamDataGrid.FieldLayouts>
                </igDP:XamDataGrid>
                <Button Grid.Row="2" Content="Populate" Command="{StaticResource PopulateCommand}" />
            </Grid>
        </ScrollViewer>
    </Grid>
</Window>

--------------------------------------------

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Configuration;
using System.Runtime.CompilerServices;
using System.Windows;

namespace GridHeight
{
    public partial class MainWindow : Window, INotifyPropertyChanged
    {

        private bool _IsExpanded = false;
        public bool IsExpanded
        {
            get => _IsExpanded;
            set => SetProperty(ref _IsExpanded, value);
        }
        public class cItem
        {
            public cItem(int i) => Item = $"Item {i}";
            public String Item { get; set; }
        }

        private List<cItem> _Items = new();

        public List<cItem>? Items
        {
            get => _Items;
            set => SetProperty(ref _Items, value);
        }

        private void Populate_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            Items = Enumerable.Range(1, 100).Select(i => new cItem(i)).ToList();
        }

        public event PropertyChangedEventHandler? PropertyChanged;
        public void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = "")
        {
            if (!Object.Equals(storage, value))
            {
                storage = value;
                if (PropertyChanged != null)
                    PropertyChanged(this, new PropertyChangedEventArgs(name));
            }
        }
    }
}

If you run this application and click the [Populate] button you can see both the grid and the scroll viewer get a scrollbar. I would only expect the grid to have a scroll bar.


The problem is not in the datagrid as I had originally thought. The grid that contains the datagrid is changing its size because the datagrid asked for more room. If we constrain the size of the grid, we can prevent it from expanding. Change the inner grid's declaration to add a Height attribute...

<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
    <Grid Height="{Binding Path=ActualHeight, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=FrameworkElement}}">
        <Grid.RowDefinitions>

Now run the application again and click [Populate]. The DataGrid stays the correct size and the Scroll Viewer does not get a scroll bar. This is what we want to see.


You can toggle the Expand expander and resize the window to verify the data grid is behaving correctly.

Friday, May 17, 2024

Preventing the Infragistics XamDockManager splitting panes

We are implementing the Infragistics XamDockManager on an existing application. Among other things the XamDockManager allows the user to split the windows into panes. Each pane shows a page from the application. Here is an example of the XamDockManager after the user has created a vertical split pane.


Our application was not designed to show such narrow pages so, like the application above, it doesn't "squish" well. One option we are considering is disabling this feature, which would mean users can only group or float windows. The users have two options for creating a split pane. They can drag onto a button or they can use the pane header's context menu.



To prevent the user from creating split panes we need to suppress both these options. Let's start with an application that allows split panes.

Start a new Visual Basic WPF application using Framework. That's the way I'm swinging today. Call it NoSplitPanes. Add references to XamDockManager and Infragistics.


Modify the MainWindow XAML to look like this

<Window x:Class="MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:NoSplitPanes"
        xmlns:igDock="http://infragistics.com/DockManager"
        mc:Ignorable="d"
        Title="No Split Panes" Height="450" Width="800">
    <igDock:XamDockManager Grid.Row="1" Grid.Column="2"
                            TabItemDragBehavior="DisplayInsertionBar"
                            UnpinnedTabHoverAction="Flyout"
                            AllowMaximizeFloatingWindows="True"
                            AllowMinimizeFloatingWindows="True"
                            ShowFloatingWindowsInTaskbar="True">
        <igDock:DocumentContentHost  >
            <igDock:SplitPane  >
                <igDock:TabGroupPane>
                    <igDock:ContentPane Header="You Can't split me">You can group us and float us, but you can't split us or pin us</igDock:ContentPane>
                    <igDock:ContentPane Header="Can't pin me either">You can't pin us because you have to split us before you can pin us</igDock:ContentPane>
                </igDock:TabGroupPane>
            </igDock:SplitPane>
        </igDock:DocumentContentHost>
    </igDock:XamDockManager>
</Window>

You can hide the buttons by styling them away. Add this style to the XamDockManager.

        <igDock:XamDockManager.Resources>
            <Style TargetType="igDock:DockedPaneSplitter">
                <Setter Property="IsEnabled" Value="False"/>
            </Style>
            <Style TargetType="igDock:SplitPaneSplitter">
                <Setter Property="IsEnabled" Value="False"/>
            </Style>
            <Style TargetType="igDock:DockingIndicator">
                <Style.Triggers>
                    <Trigger Property="Position" Value="Top">
                        <Setter Property="Visibility" Value="Collapsed"/>
                    </Trigger>
                    <Trigger Property="Position" Value="Bottom">
                        <Setter Property="Visibility" Value="Collapsed"/>
                    </Trigger>
                    <Trigger Property="Position" Value="Left">
                        <Setter Property="Visibility" Value="Collapsed"/>
                    </Trigger>
                    <Trigger Property="Position" Value="Right">
                        <Setter Property="Visibility" Value="Collapsed"/>
                    </Trigger>
                    <Trigger Property="Position" Value="Center">
                        <Setter Property="Visibility" Value="Collapsed"/>
                    </Trigger>
                </Style.Triggers>
            </Style>
        </igDock:XamDockManager.Resources>

You can disabled the context menu items by writing am OptionsMenuOpening event handler. Start by added the event to a new style.

            <Style TargetType="igDock:ContentPane">
                <EventSetter Event="OptionsMenuOpening" Handler="ContentPane_DisableNewTabGroup"/>
            </Style>

The event handler goes in the code behind and suppresses menu items based on their header. It's a bit of a kludge.

Imports Infragistics.Windows.DockManager.Events

Class MainWindow
    Private Sub ContentPane_DisableNewTabGroup(sender As Object, e As PaneOptionsMenuOpeningEventArgs)
        For Each item As MenuItem In e.Items.OfType(Of MenuItem).Where(Function(i) i.Header IsNot Nothing)
            If item.Header.ToString().Contains("Tab Group") Then    ' Fine tune this to disable whatever you want
                item.IsEnabled = False
            End If
        Next
    End Sub

End Class

Now when you drag a pane the docking buttons are hidden and the context menu has the new Tab Group options disabled.




Wednesday, April 3, 2024

I found a bug in the Visual Basic editor of Visual Studio

I remember 50 years ago my math teacher spent a lesson on arithmetic precision, or the importance of knowing how accurate your numbers are.

For example...

1.4  <= I am confident this number lies between 1.35 and 1.45

1.40 <= I am confident this number lies between 1.395 and 1.405

There is a difference, even if it's not always important.

In .Net decimal variables know their precision. For example, in C# the code

            float f = 1.40F;
            Console.WriteLine(f);
            decimal d = 1.40M;
            Console.WriteLine(d);

outputs

1.4 1.40

As you can see, floats (and doubles) do not know their precision but Decimals do.

Similarly in Visual Basic, decimals understand their precision but you cannot enter a decimal literal with trailing zeros because the editor won't let you.

Dim d As Decimal = 1.40D    <= The editor removes the trailing zero as soon as you leave the line

However, you can initialize the decimal with a Decimal.Parse and the trailing zero is honored.

        Dim d As Decimal = Decimal.Parse("1.40")
        Console.WriteLine(d)

outputs

1.40

So it's clear both C# and Visual Basic treat decimals the same internally (as I would expect) but the Visual Basic editor has a bug that makes it think it should remove trailing zeroes even when it should not.

Looking through the editor options and Googling does not reveal a way to suppress this behavior. It's just a bug.

Friday, March 1, 2024

A converter to filter lists before binding to them

A member of my team has a user control with two dropdown lists on it. The dropdown lists were to display the same items except that one of them was to exclude one of the items. He could have created two different collections and bound each dropdown list to its own collection, but he decided to write a converter for one of them that would exclude the unwanted item. In my opinion, this is the superior solution.

But he wrote a converter that was specific to this one requirement and it occurred to me that it should be possible to write a more generic converter using reflection. So I took a crack at it and this is my solution.

The idea is that you bind the dropdown list's ItemSource to a collection and pass a filter in as the converter parameter. Something like 'ID <> 0'. The converter will only return items whose ID is not zero, thus removing the item whose ID is zero.

Start a new Visual Studio C# project called FilteredEnumerableConverterDemo. 

Add a class called Converters and add the FilteredEnumerableConverter like this. It only supports Lists and ObservableCollections. The parameter has very strict syntax and only supports basic comparisons. Feel free to use it as a starting point to add ranges, lists, startswith, etc.

using System.Globalization;
using System.Reflection;
using System.Windows.Data;

namespace FilteredEnumerableConverterDemo
{
    public class FilteredEnumerableConverter : IValueConverter
    {
        /// <summary>
        ///     Returns only the enumerable members with properties that match the filter specified in the parameter
        /// </summary>
        /// <param name="value">A List or ObservableCollection</param>
        /// <param name="targetType"></param>
        /// <param name="parameter">filter in the form <propertyname> <op> <value> </param>
        /// <param name="culture"></param>
        /// <returns>An Enumerable containing only the members that match the filter</returns>
        /// <exception cref="NotImplementedException"></exception>
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (string.IsNullOrWhiteSpace(parameter?.ToString())) return value;
            if (value == null) return null;

            try
            {
                if (new[] { "List`1", "ObservableCollection`1" }.Contains(value.GetType().Name))
                    return FilterList((System.Collections.IEnumerable)value, parameter.ToString());
                else
                    return value;
            }
            catch (Exception ex)
            {
                throw new Exception("FilteredEnumerableConverter:" + ex.Message);
            }
        }

        private System.Collections.IEnumerable FilterList(System.Collections.IEnumerable list, string filter)
        {
            List<object> newList = new List<object>();
            String[] filterParts = filter.Split(' ');

            if (filterParts.Length != 3) return list;

            String propertyName = filterParts[0];
            String op = filterParts[1];
            String targetValue = filterParts[2];
            String sourceValue;
            bool useElement;

            Type T = list.GetType().GetGenericArguments()[0];
            PropertyInfo PI = T.GetProperty(propertyName);
            if (PI == null) return list;

            foreach (object element in list)
            {
                useElement = false;
                sourceValue = PI.GetValue(element).ToString();
                switch (op)
                {
                    case "=": useElement = (sourceValue == targetValue); break;
                    case ">": useElement = (sourceValue.CompareTo(targetValue) > 0); break;
                    case "<": useElement = (sourceValue.CompareTo(targetValue) < 0); break;
                    case ">=": useElement = (sourceValue.CompareTo(targetValue) >= 0); break;
                    case "<=": useElement = (sourceValue.CompareTo(targetValue) <= 0); break;
                    case "!=":
                    case "<>": useElement = (sourceValue != targetValue); break;
                }
                if (useElement)
                    newList.Add(element);
            }

            return newList;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return value;
        }
    }
}

Now let's consume this converter.

Change MainWindow to look like this...

<Window x:Class="FilteredEnumerableConverterDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:FilteredEnumerableConverterDemo"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <local:FilteredEnumerableConverter x:Key="FilteredEnumerableConverter"/>
    </Window.Resources>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100"/>
            <ColumnDefinition Width="100"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0" Grid.Column="0" Text="All animals"/>
        <ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Animals}" DisplayMemberPath="Name" HorizontalAlignment="Stretch"/>

        <TextBlock Grid.Row="1" Grid.Column="0" Text="Dangerous animals"/>
        <ComboBox Grid.Row="1" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='IsDangerous = True'}"/>

        <TextBlock Grid.Row="2" Grid.Column="0" Text="Edible animals"/>
        <ComboBox Grid.Row="2" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='IsEdible = True'}"/>
    </Grid>
</Window>

----------------------------------------------------------
using System.Windows;

namespace FilteredEnumerableConverterDemo
{
    public partial class MainWindow : Window
    {
        public class cAnimal
        {
            public String Name { get; set; }
            public bool IsDangerous { get; set; }
            public bool IsEdible {  get; set; }
        }

        public List<cAnimal> Animals { get; set; } = new List<cAnimal>()
        {
            new cAnimal() {Name="Lion", IsDangerous=true, IsEdible=false},
            new cAnimal() {Name="Cockroach", IsDangerous=false, IsEdible=false},
            new cAnimal() {Name="Cow", IsDangerous=false, IsEdible=true},
            new cAnimal() {Name="Rattlesnake", IsDangerous=true, IsEdible=true}
        };

        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

If you run the application you can see three dropdown lists, all populated from the same collection, but with different lists.




Note: < and > operators have to be XML encoded. eg.

        <TextBlock Grid.Row="3" Grid.Column="0" Text="A-M only"/>
        <ComboBox Grid.Row="3" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='Name &lt;= M'}"/>



Monday, February 26, 2024

Creating a meaningful and unique file name

We have applications that create a report when the user hits a button. The application creates a temporary file which we pass to Process.Start to launch the appropriate viewer (normally, but not always, Acrobat Reader). The temporary file is named using the GetTempFileName function and dropped in the temporary folder. One advantage to using this technique is it guarantees a unique file name.

Imagine that the user is looking at document ABC and presses the Print button. We generate a file called "Document ABC.pdf" and launch Acrobat Reader. Then the user presses the Print button again. We cannot create a new Document ABC.pdf file because the original is still open in Acrobat Reader. That's the advantage of GetTempFileName.

In addition we have applications that can generate reports for arbitrary lists of documents. If the file name lists all the document numbers, we could exceed the maximum file name length.

I wrote a function to do all this over the weekend. It is written as a VB console app. It has nothing to do with WPF, but it was interesting.

Imports System.IO
Imports System.Reflection.Metadata.Ecma335

Module Program
    Sub Main(args As String())
        Dim DocumentType As String = "PO"
        Dim DocumentNumbers As New List(Of Integer) From {123, 240001, 240010, 240017, 240002, 240003, 240016}
        Dim FileTypeSuffix As String = "pdf"
        Dim FolderName As String = IO.Path.GetTempPath()
        Dim FileName As String = ""

        Try
            FileName = GetUniqueFileName(FolderName, DocumentType, DocumentNumbers, FileTypeSuffix)

            ' Make sure we got a valid, creatable file name
            File.Create(Path.Combine(FolderName, FileName), 256, FileOptions.DeleteOnClose).Close()
            Console.WriteLine(Path.Combine(FolderName, FileName))
        Catch ex As Exception
            Console.WriteLine(ex.Message)
        End Try
        Console.ReadLine()

    End Sub

    ''' <summary>
    ''' Return a unique and meaningful file name
    ''' </summary>
    ''' <param name="DocumentType">I description of the types of document in the list. Included in the file name</param>
    ''' <param name="DocumentNumbers">A list of document numbers</param>
    ''' <param name="FileTypeSuffix">The suffix for the file name</param>
    ''' <returns>A unique and meaningful filaname. Could delete an existing file</returns>
    Function GetUniqueFileName(FolderName As String, DocumentType As String, DocumentNumbers As List(Of Integer), FileTypeSuffix As String) As String

        Dim DocumentNumberRanges As New Dictionary(Of Integer, Integer)()
        Dim DocumentNumberRangesAsString As New List(Of String)()
        Dim DocumentFormatString As String = "000000"
        Dim DateFormatString As String = " yyyy-MM-dd"  ' If you want to add time, remember you cannot have colons in file names
        Dim MaxDocumentNumbers As Integer = 5
        Dim StrictlyEnforceMax As Boolean = False       ' When false, this uses the full range if the list ends with a range
        Dim DocumentNumberCount As Integer = 0
        Dim IsMoreDocuments As Boolean = False
        Dim FileName As String = ""
        Dim DeleteExistingFile As Boolean = True        ' Can we delete an existing file to enforce uniqueness?

        Try
            DocumentNumberRanges = ConvertListToRanges(DocumentNumbers)
            For Each Range As KeyValuePair(Of Integer, Integer) In DocumentNumberRanges
                If DocumentNumberCount < MaxDocumentNumbers Then
                    If Range.Key = Range.Value OrElse (DocumentNumberCount + 2 > MaxDocumentNumbers And StrictlyEnforceMax) Then
                        DocumentNumberRangesAsString.Add(Range.Key.ToString(DocumentFormatString))
                        DocumentNumberCount += 1
                    Else
                        DocumentNumberRangesAsString.Add($"{Range.Key.ToString(DocumentFormatString)}-{Range.Value.ToString(DocumentFormatString)}")
                        DocumentNumberCount += 2
                    End If
                Else
                    IsMoreDocuments = True
                End If
            Next
            FileName = $"{DocumentType} {String.Join(",", DocumentNumberRangesAsString)}{If(IsMoreDocuments, "...", "")}{Date.Now.ToString(DateFormatString)}"
            FileName = UniquifyFileName(FolderName, FileName, FileTypeSuffix, DeleteExisting:=DeleteExistingFile)
            Return FileName
        Catch ex As Exception
            Throw New Exception("GetUniqueFileName:" & ex.Message)
        End Try

    End Function

    ''' <summary>
    ''' Convert a random list of values into a sorted list of ranges
    ''' </summary>
    ''' <param name="Values">A list of numbers</param>
    ''' <returns>The list sorted into a list of ranges</returns>
    Function ConvertListToRanges(Values As List(Of Integer)) As Dictionary(Of Integer, Integer)
        Dim Ranges As New Dictionary(Of Integer, Integer)

        Try
            Values.Sort()
            For Each Value As Integer In Values
                If Ranges.Count = 0 OrElse Value <> Ranges.Last().Value + 1 Then
                    Ranges.Add(Value, Value)
                Else
                    Ranges(Ranges.Last().Key) = Value
                End If
            Next
            Return Ranges
        Catch ex As Exception
            Throw New Exception("ConvertListToRanges:" & ex.Message)
        End Try
    End Function

    ''' <summary>
    ''' Uniquifies a file name by inserting (Copy n) to avoid conflicting with an existing file
    ''' </summary>
    ''' <param name="FileName">The file name with no suffix</param>
    ''' <param name="FileTypeSuffix">The file suffix</param>
    ''' <param name="DeleteExisting">Uniquify the file name by deleting the existing file if possible</param>
    ''' <returns>A unique file name in the form {filename}[ (Copy n)].{suffix}</returns>
    Function UniquifyFileName(FolderName As String, FileName As String, FileTypeSuffix As String, DeleteExisting As Boolean) As String

        Dim NewFileName As String = $"{FileName}.{FileTypeSuffix}"
        Dim CopyNumber As Integer = 0
        Dim FileExists As Boolean

        Try
            Do
                FileExists = File.Exists(Path.Combine(FolderName, NewFileName))
                If FileExists And DeleteExisting Then
                    Try
                        File.Delete(Path.Combine(FolderName, NewFileName))
                        FileExists = False
                    Catch ex As Exception
                        ' We could not delete it
                    End Try
                End If
                If FileExists Then
                    CopyNumber += 1
                    NewFileName = $"{FileName} (Copy {CopyNumber}).{FileTypeSuffix}"
                End If
            Loop Until Not FileExists
        Catch ex As Exception
            Throw New Exception("ApplyCopyNumber:" & ex.Message)
        End Try
        Return NewFileName

    End Function
End Module

If you run the program you get this output.