DEV Community

Cover image for Sleek & Modern WPF: Build a Real-Time Word Counter Dashboard (C# Tutorial)
VectoArt
VectoArt

Posted on

Sleek & Modern WPF: Build a Real-Time Word Counter Dashboard (C# Tutorial)

If you’ve ever needed a fast, clean word counter, you know they often look pretty boring. Today, we’re changing that. We’re going to build this sleek, dark-themed Real-Time Word Counter Dashboard using WPF and C#.

This project is a perfect hands-on exercise for mastering fundamental WPF concepts like Data Binding, the MVVM Pattern (Simplified), custom XAML Styling, and some neat C# Regex tricks for accurate text analysis.

Let’s dive in and transform a standard desktop application into a modern tool.

Prerequisites

  1. Visual Studio (Community Edition or higher)
  2. A new WPF Application (.NET) or WPF App (.NET Framework) project.

Step 1: Set Up the Data Model (The ‘M’ in MVVM)

The foundation of any dynamic WPF application is a robust data model that can notify the UI of changes. This is achieved using the INotifyPropertyChanged interface.

We’ll create a simple data class, StatisticItem, to represent each card in our dashboard (Words, Characters, Sentences, etc.).

Add this class inside your MainWindow.xaml.cs file (or a separate Models folder):

// StatisticItem.cs (or inside MainWindow.xaml.cs)

using System.ComponentModel;
using System.Windows.Media;

public class StatisticItem : INotifyPropertyChanged
{
    private string _value = "0";

    // Value changes and notifies the UI
    public string Value 
    {
        get => _value;
        set
        {
            if (_value != value)
            {
                _value = value;
                OnPropertyChanged(nameof(Value)); 
            }
        }
    }

    // Static properties for UI definition (Label, Icon, Color)
    public string Label { get; set; }       
    public string IconGlyph { get; set; }   
    public SolidColorBrush IconColor { get; set; } 

    // Standard INotifyPropertyChanged Implementation
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the Main Logic (The Simplified ‘VM’ and Code-Behind)

In your MainWindow.xaml.cs, we will initialize the statistics list, set the DataContext, and include the core logic for counting the text.

The UpdateCounts() method is where the analysis happens, using powerful Regular Expressions (Regex) for accurate word and sentence segmentation.

Replace the contents of your MainWindow.xaml.cs with the following code:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace WordCounterApp
{
    // -------------------------------------------------------------------------
    // 1. STATISTIC MODEL CLASS
    // Used to hold and display the data for each of the five tiles.
    // -------------------------------------------------------------------------
    public class StatisticItem : INotifyPropertyChanged
    {
        private string _value = "0";
        public string Value
        {
            get => _value;
            set
            {
                if (_value != value)
                {
                    _value = value;
                    OnPropertyChanged(nameof(Value));
                }
            }
        }

        public string Label { get; set; }
        public string IconGlyph { get; set; } // Segoe MDL2 Assets glyph code
        public SolidColorBrush IconColor { get; set; }

        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    // -------------------------------------------------------------------------
    // 2. MAIN WINDOW LOGIC
    // -------------------------------------------------------------------------
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        private string _inputText = "";

        public string InputText
        {
            get => _inputText;
            set
            {
                if (_inputText != value)
                {
                    _inputText = value;
                    OnPropertyChanged(nameof(InputText));
                }
            }
        }

        public List<StatisticItem> Statistics { get; set; }

        public MainWindow()
        {
            // Initialize the list of statistics with their static properties (Labels, Icons, Colors)
            Statistics = new List<StatisticItem>
            {
                new StatisticItem { Label = "WORDS", IconGlyph = "\xE762", IconColor = (SolidColorBrush)new BrushConverter().ConvertFromString("#4CAF50") }, // Green, Chart Icon
                new StatisticItem { Label = "CHARACTERS", IconGlyph = "\xE7C3", IconColor = (SolidColorBrush)new BrushConverter().ConvertFromString("#2196F3") }, // Blue, Document Icon
                new StatisticItem { Label = "SENTENCES", IconGlyph = "\xE8A4", IconColor = (SolidColorBrush)new BrushConverter().ConvertFromString("#9C27B0") }, // Purple, Question/Exclamation Mark Icon
                new StatisticItem { Label = "PARAGRAPHS", IconGlyph = "\xE8E2", IconColor = (SolidColorBrush)new BrushConverter().ConvertFromString("#FF9800") }, // Orange, Text Align Icon
                new StatisticItem { Label = "~MIN READ", IconGlyph = "\xE916", IconColor = (SolidColorBrush)new BrushConverter().ConvertFromString("#00BCD4") } // Teal, Clock/Time Icon
            };

            InitializeComponent();

            // Set the DataContext so XAML bindings work against this object
            DataContext = this;

            // Initialize counts
            UpdateCounts();
        }

        // ---------------------------------------------------------------------
        // DATA COUNTING LOGIC
        // ---------------------------------------------------------------------

        private void InputTextBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            // The InputText property is already updated via TwoWay binding,
            // so we just need to call the count update method.
            UpdateCounts();
        }

        private void UpdateCounts()
        {
            string text = InputText.Trim();

            // 1. Character Count
            int characterCount = text.Length;

            // 2. Word Count
            // Use regex to find sequences of word characters (\w+) separated by word boundaries (\b)
            int wordCount = Regex.Matches(text, @"\b\w+\b").Count;

            // 3. Sentence Count
            // Find periods, question marks, or exclamation marks followed by a space or the end of the string
            int sentenceCount = Regex.Matches(text, @"[.!?](\s|$)").Count;
            // Handle edge case of completely empty input
            if (string.IsNullOrWhiteSpace(text)) { sentenceCount = 0; }
            // Handle case where text exists but has no punctuation (e.g., a single phrase)
            else if (sentenceCount == 0 && wordCount > 0) { sentenceCount = 1; }


            // 4. Paragraph Count
            // Split by double newline characters, removing empty entries (standard paragraph separation)
            string[] paragraphs = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries);
            int paragraphCount = paragraphs.Length;
            if (string.IsNullOrWhiteSpace(text)) { paragraphCount = 0; } // Ensure empty input is 0

            // 5. Min Read (assuming average reading speed of 200 Words Per Minute)
            double minRead = (double)wordCount / 200.0;
            string minReadDisplay = minRead < 1.0 ? $"{(int)(minRead * 60)} SEC" : $"{minRead:0.0} MIN";
            if (wordCount == 0) { minReadDisplay = "0"; }

            // Update the bound properties
            Statistics[0].Value = wordCount.ToString("N0");
            Statistics[1].Value = characterCount.ToString("N0");
            Statistics[2].Value = sentenceCount.ToString("N0");
            Statistics[3].Value = paragraphCount.ToString("N0");
            Statistics[4].Value = minReadDisplay;
        }

        // ---------------------------------------------------------------------
        // BUTTON EVENT HANDLERS
        // ---------------------------------------------------------------------

        private void ClearText_Click(object sender, RoutedEventArgs e)
        {
            InputText = "";
            InputTextBox.Focus();
        }

        private void CopyText_Click(object sender, RoutedEventArgs e)
        {
            if (!string.IsNullOrEmpty(InputText))
            {
                Clipboard.SetText(InputText);
            }
        }

        // ---------------------------------------------------------------------
        // INotifyPropertyChanged IMPLEMENTATION
        // ---------------------------------------------------------------------
        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The magic happens inside the UpdateCounts() method, which is called every time the text in our main input box changes.

The challenge here is not just counting characters, but accurately counting words, sentences, and paragraphs, which requires handling tricky punctuation and whitespace.

  • Character Count: This is the easiest. We just take the length of the string after trimming any excess space: text.Length.
  • Word Count: For accurate word counting, we use a regular expression. This expression looks for word characters (\w+) separated by boundaries (\b). This correctly ignores punctuation and multiple spaces:
int wordCount = Regex.Matches(text, @"\b\w+\b").Count;
Enter fullscreen mode Exit fullscreen mode
  • Sentence Count: Similarly, we use Regex to find end-of-sentence punctuation — periods, question marks, or exclamation points — that are immediately followed by a space or the end of the input string:
int sentenceCount = Regex.Matches(text, @"[.!?](\s|$)").Count;
Enter fullscreen mode Exit fullscreen mode
  • Min Read: We calculate the reading time assuming a standard speed of 200 words per minute. We use a little conditional logic to display it as seconds if it’s under a minute, or minutes otherwise:
double minRead = (double)wordCount / 200.0; string minReadDisplay = minRead < 1.0 ? $"{(int)(minRead * 60)} SEC" : $"{minRead:0.0} MIN";
Enter fullscreen mode Exit fullscreen mode

Finally, we update the Value property of each StatisticItem in our list. Since these items implement INotifyPropertyChanged, the UI automatically refreshes!

Step 3: Design the Modern Dark UI (XAML)

The dark mode design uses two primary colors: a deep background (#1F2430) and a slightly lighter panel color (#2A303C).

The key to a clean, maintainable dashboard is the ItemsControl. Instead of manually defining five statistic cards, we bind the control to our Statistics list and use an ItemTemplate and UniformGrid to lay out the data automatically.

Replace the contents of your MainWindow.xaml file:

<Window x:Class="WordCounterApp.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:WordCounterApp"
        mc:Ignorable="d"
        Title="Word Counter" Height="650" Width="900"
        Background="#1F2430">

    <!-- Window/App-wide Styles -->
    <Window.Resources>
        <!-- Style for the statistic numbers (0) -->
        <Style x:Key="StatisticValue" TargetType="TextBlock">
            <Setter Property="FontSize" Value="32"/>
            <Setter Property="FontWeight" Value="Bold"/>
            <Setter Property="Foreground" Value="#E0E6F0"/>
            <Setter Property="Margin" Value="0,10,0,5"/>
            <Setter Property="HorizontalAlignment" Value="Center"/>
        </Style>

        <!-- Style for the statistic labels (WORDS, CHARACTERS, etc.) -->
        <Style x:Key="StatisticLabel" TargetType="TextBlock">
            <Setter Property="FontSize" Value="14"/>
            <Setter Property="Foreground" Value="#9AA3B5"/>
            <Setter Property="HorizontalAlignment" Value="Center"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
        </Style>

        <!-- Style for the statistic card icons -->
        <Style x:Key="StatisticIcon" TargetType="TextBlock">
            <Setter Property="FontSize" Value="24"/>
            <Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
            <Setter Property="Width" Value="30"/>
            <Setter Property="Height" Value="30"/>
            <!-- 
            *** FIX APPLIED HERE: CornerRadius property removed from TextBlock style. ***
            The CornerRadius is correctly applied to the Border element in the ItemTemplate. 
            <Setter Property="CornerRadius" Value="8"/> 
            -->
            <Setter Property="VerticalAlignment" Value="Center"/>
            <Setter Property="HorizontalAlignment" Value="Left"/>
            <Setter Property="Padding" Value="6"/>
            <Setter Property="TextAlignment" Value="Center"/>
            <Setter Property="Foreground" Value="White"/>
            <Setter Property="Margin" Value="15,0,0,0"/>
        </Style>

        <!-- Style for the main content panels (Input/Statistics) -->
        <Style x:Key="PanelHeader" TargetType="TextBlock">
            <Setter Property="FontSize" Value="20"/>
            <Setter Property="FontWeight" Value="SemiBold"/>
            <Setter Property="Foreground" Value="#E0E6F0"/>
            <Setter Property="Margin" Value="0,0,0,10"/>
        </Style>
    </Window.Resources>

    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <!-- Header -->
            <RowDefinition Height="*"/>
            <!-- Input Text Area -->
            <RowDefinition Height="Auto"/>
            <!-- Statistics Header -->
            <RowDefinition Height="Auto"/>
            <!-- Statistics Cards -->
        </Grid.RowDefinitions>

        <!-- 1. Header -->
        <DockPanel Grid.Row="0" Margin="0,0,0,10">
            <!-- Simple Calculator Icon (using Segoe MDL2 Assets) -->
            <TextBlock FontSize="28" Foreground="#FF9800" VerticalAlignment="Center" Margin="0,0,10,0"
                       Text="🧾"/>
            <TextBlock Text="Word Counter" FontSize="28" FontWeight="Bold" Foreground="#E0E6F0"/>
        </DockPanel>

        <!-- 2. Input Text Area Panel -->
        <Border Grid.Row="1" Background="#2A303C" CornerRadius="10" Padding="15" Margin="0,0,0,20">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>

                <!-- Input Header and Actions -->
                <DockPanel Grid.Row="0" Margin="0,0,0,10">
                    <StackPanel Orientation="Horizontal" DockPanel.Dock="Right">
                        <!-- Clear Button (Trash icon) -->
                        <Button Style="{StaticResource {x:Static ToolBar.ButtonStyleKey}}" 
                                Click="ClearText_Click" Margin="0,0,5,0" ToolTip="Clear Text">
                            <TextBlock Text="&#xE74D;" FontFamily="Segoe MDL2 Assets" Foreground="#9AA3B5" FontSize="16"/>
                        </Button>
                        <!-- Copy Button (Clipboard icon) -->
                        <Button Style="{StaticResource {x:Static ToolBar.ButtonStyleKey}}" 
                                Click="CopyText_Click" ToolTip="Copy Text to Clipboard">
                            <TextBlock Text="&#xE8C8;" FontFamily="Segoe MDL2 Assets" Foreground="#9AA3B5" FontSize="16"/>
                        </Button>
                    </StackPanel>
                    <TextBlock Text="Input Text" Style="{StaticResource PanelHeader}"/>
                </DockPanel>

                <!-- Input TextBox -->
                <TextBox Grid.Row="1" 
                         x:Name="InputTextBox" 
                         Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" 
                         AcceptsReturn="True" 
                         TextWrapping="Wrap"
                         VerticalScrollBarVisibility="Auto"
                         Background="#1F2430"
                         Foreground="#E0E6F0"
                         BorderBrush="#3B4450"
                         BorderThickness="1"
                         Padding="10"
                         FontSize="16"
                         CaretBrush="#E0E6F0"
                         TextChanged="InputTextBox_TextChanged">
                    <TextBox.Resources>
                        <Style TargetType="{x:Type Border}">
                            <Setter Property="CornerRadius" Value="5"/>
                        </Style>
                    </TextBox.Resources>
                    <!-- Placeholder Text -->
                    <TextBox.ToolTip>
                        <TextBlock Text="Paste or type your text here..."/>
                    </TextBox.ToolTip>
                </TextBox>

            </Grid>
        </Border>

        <!-- 3. Statistics Header -->
        <TextBlock Grid.Row="2" Text="Statistics" Style="{StaticResource PanelHeader}" Margin="0,0,0,10"/>

        <!-- 4. Statistics Cards: Refactored to use ItemsControl with a UniformGrid ItemsPanel -->
        <ItemsControl Grid.Row="3" ItemsSource="{Binding Statistics}" MinHeight="150">

            <!-- ItemsPanel defines the layout for the collection (a 5-column UniformGrid) -->
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <UniformGrid Columns="5"/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>

            <!-- ItemTemplate defines the visual appearance of each StatisticItem -->
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border Background="#2A303C" CornerRadius="10" Margin="5" Padding="10" Width="Auto">
                        <StackPanel VerticalAlignment="Center">
                            <!-- Icon Placeholder -->
                            <Border Width="40" Height="40" CornerRadius="8" Padding="0,10,0,0" 
                                    HorizontalAlignment="Left" Margin="5,0,0,10"
                                    Background="{Binding IconColor}">
                                <TextBlock Text="{Binding IconGlyph}" Style="{StaticResource StatisticIcon}" 
                                           Foreground="White" FontFamily="Segoe MDL2 Assets" Margin="0" Padding="0"/>
                            </Border>

                            <!-- Value -->
                            <TextBlock Text="{Binding Value}" Style="{StaticResource StatisticValue}"/>

                            <!-- Label -->
                            <TextBlock Text="{Binding Label}" Style="{StaticResource StatisticLabel}" Margin="0,0,0,5"/>
                        </StackPanel>
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>

        </ItemsControl>

    </Grid>
</Window>
Enter fullscreen mode Exit fullscreen mode
  1. Dark Theme: We set the main window background to a dark gray #1F2430 and use a slightly lighter gray #2A303C for the panel backgrounds. This contrast is key.
  2. Layout: We use a simple Grid with four rows: Header, Input Area, Statistics Header, and the Statistics Cards. The Input Area row uses Height="*" so it expands to fill the remaining space.
  3. The Input Box: The main TextBox is bound directly to our InputText property in C#. Crucially, we use the TextChanged="InputTextBox_TextChanged" event, which triggers our UpdateCounts() method every time the user types.
  4. The Statistics Cards (ItemsControl Fix): This is where we make the magic happen with the list. Instead of manually creating five cards, we use an ItemsControl bound to our **Statistics **list:
<ItemsControl Grid.Row="3" ItemsSource="{Binding Statistics}">
    <ItemsControl.ItemsPanel>
        <!-- Lays out the items in a 5-column grid -->
        <ItemsPanelTemplate>
            <UniformGrid Columns="5"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <!-- Defines the look of ONE single StatisticItem -->
        <DataTemplate>
            <Border Background="#2A303C" CornerRadius="10" Margin="5" Padding="10">
                <StackPanel>
                    <!-- Icon Border, uses IconColor and CornerRadius -->
                    <Border Background="{Binding IconColor}" CornerRadius="8">
                        <TextBlock Text="{Binding IconGlyph}" Style="{StaticResource StatisticIcon}"/>
                    </Border>
                    <!-- Value Block -->
                    <TextBlock Text="{Binding Value}" Style="{StaticResource StatisticValue}"/>
                    <!-- Label Block -->
                    <TextBlock Text="{Binding Label}" Style="{StaticResource StatisticLabel}"/>
                </StackPanel>
            </Border>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>
Enter fullscreen mode Exit fullscreen mode

The ItemsControl automatically loops through our Statistics list, uses the **UniformGrid **to arrange them evenly, and applies the beautiful dark-themed design from the **ItemTemplate **to each one.

Conclusion

And there you have it! A fully functional, modern word counter built entirely with WPF and C#. We covered data modeling with INotifyPropertyChanged, effective counting using Regex, and powerful UI layout using the ItemsControl.

You can use the principles here for any dashboard or statistics panel you need to build in WPF.

If you have any questions about the Regex, the MVVM binding, or want to know how to add features like syllable count, let me know in the comments below!

Happy coding, and I’ll see you in the next one!

Top comments (0)