Search code examples
c#.netwpfxamltextblock

Display DateTime in TextBlock with Dashed-Underlined TextDecorations


I'm trying to display a DateTime formatted like 2019-10-07 17:00 in a TextBlock. The text should be underlined and dashed. To do this I'm using the following xaml

<TextBlock Text="2019-10-07 17:00">
    <TextBlock.TextDecorations>
        <TextDecoration Location="Underline">
            <TextDecoration.Pen>
                <Pen Brush="Black">
                    <Pen.DashStyle>
                        <DashStyle Dashes="5"/>
                    </Pen.DashStyle>
                </Pen>
            </TextDecoration.Pen>
        </TextDecoration>
    </TextBlock.TextDecorations>
</TextBlock>

However, this produces some very unexpected results where it seems like each hyphen causes the dashed underline to restart its rendering. Notice the dash-pattern which looks almost random efter each hyphen.

enter image description here

If I change the "minus-sign-hyphen" to "non-breaking-hyphen" which looks very similar (- vs ‐), the rendering works as expected.

<TextBlock Text="2019‐10‐07 17:00" ...>

enter image description here

This buggy rendering of the dashed underline happends everytime I add a minus-sign-hyphen to the text but not with any other character that I could find. Has anyone else noticed this and does anyone have a solution? If not, what might be the reason for this weird behavior?

enter image description here


Solution

  • In the end, we built a custom control called DashTextBlock to solve this issue. It derives from TextBox and is styled like a TextBlock with an added TextDecoration that uses a Pen with a LinearGradientBrush that is set up according to whatever what specified as "dash-properties" and the thickness of DashThickness.

    To achieve this it uses the TextBox method GetRectFromCharacterIndex to figure out how to setup the LinearGradientBrush.

    TextBox.GetRectFromCharacterIndex Method

    Returns the rectangle for an edge of the character at the specified index.

    It produces results like this

    enter image description here

    Sample usage

        <StackPanel>
            <controls:DashTextBlock Text="Testing DashTextBlock"
                                    DashThickness="1"
                                    DashColor="Blue">
                <controls:DashTextBlock.DashStyle>
                    <DashStyle Dashes="4,4,4,4" Offset="0" />
                </controls:DashTextBlock.DashStyle>
            </controls:DashTextBlock>
    
            <controls:DashTextBlock Text="Testing DashTextBlock"
                                    Margin="0 5 0 0"
                                    DashThickness="2"
                                    DashColor="Orange">
                <controls:DashTextBlock.DashStyle>
                    <DashStyle Dashes="8 4 8 4" Offset="0" />
                </controls:DashTextBlock.DashStyle>
            </controls:DashTextBlock>
        </StackPanel>
    

    DashTextBlock

        public class DashTextBlock : TextBox
        {
            public static readonly DependencyProperty DashColorProperty =
                DependencyProperty.Register("DashColor",
                                            typeof(Color),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(Colors.Black, OnDashColorChanged));
    
            public static readonly DependencyProperty DashThicknessProperty =
                DependencyProperty.Register("DashThickness",
                                            typeof(double),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(1.0, OnDashThicknessChanged));
    
            public static readonly DependencyProperty DashStyleProperty =
                DependencyProperty.Register("DashStyle",
                                            typeof(DashStyle),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(DashStyles.Solid, OnDashStyleChanged));
    
            private static readonly DependencyProperty FontSizeCallbackProperty =
                DependencyProperty.Register("FontSizeCallback",
                                            typeof(double),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(0.0, OnFontSizeCallbackChanged));
    
            public static readonly DependencyProperty TextLengthProperty =
                DependencyProperty.Register("TextLength",
                                            typeof(double),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(0.0));
    
            public static readonly DependencyProperty DashEnabledProperty =
                DependencyProperty.Register("DashEnabled",
                                            typeof(bool),
                                            typeof(DashTextBlock),
                                            new FrameworkPropertyMetadata(true, OnDashEnabledChanged));
    
            private static void OnDashColorChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
            {
                DashTextBlock dashTextBlock = source as DashTextBlock;
                dashTextBlock.DashColorChanged();
            }
    
            private static void OnDashThicknessChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
            {
                DashTextBlock dashTextBlock = source as DashTextBlock;
                dashTextBlock.DashThicknessChanged();
            }
    
            private static void OnDashStyleChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
            {
                DashTextBlock dashTextBlock = source as DashTextBlock;
                dashTextBlock.DashStyleChanged();
            }
    
            private static void OnFontSizeCallbackChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
            {
                DashTextBlock dashTextBlock = source as DashTextBlock;
                dashTextBlock.FontSizeChanged();
            }
    
            private static void OnDashEnabledChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
            {
                DashTextBlock dashTextBlock = source as DashTextBlock;
                dashTextBlock.DashEnabledChanged();
            }
    
            private static Pen _transparentPen;
    
            static DashTextBlock()
            {
                _transparentPen = new Pen(Brushes.Transparent, 0);
                _transparentPen.Freeze();
    
                DefaultStyleKeyProperty.OverrideMetadata(typeof(DashTextBlock), new FrameworkPropertyMetadata(typeof(DashTextBlock)));
            }
    
            private TextDecoration _dashDecoration = new TextDecoration();
    
            public DashTextBlock()
            {
                Binding fontSizeCallbackBinding = new Binding();
                fontSizeCallbackBinding.Source = this;
                fontSizeCallbackBinding.Path = new PropertyPath(TextBlock.FontSizeProperty);
                this.SetBinding(FontSizeCallbackProperty, fontSizeCallbackBinding);
                TextChanged += DashTextBlock_TextChanged;
                this.LayoutUpdated += DashTextBlock_LayoutUpdated;
            }
    
            private void DashTextBlock_LayoutUpdated(object sender, EventArgs e)
            {
                if (IsLoaded)
                {
                    var textRect = GetRectFromCharacterIndex(Text.Length);
                    double availableWidth = textRect.Right;
                    if (textRect.IsEmpty == false &&
                        availableWidth > 0)
                    {
                        this.LayoutUpdated -= DashTextBlock_LayoutUpdated;
                        UpdateTextWithDashing();
                    }
                }
            }
    
            public Color DashColor
            {
                get { return (Color)GetValue(DashColorProperty); }
                set { SetValue(DashColorProperty, value); }
            }
    
            public double DashThickness
            {
                get { return (double)GetValue(DashThicknessProperty); }
                set { SetValue(DashThicknessProperty, value); }
            }
    
            public DashStyle DashStyle
            {
                get { return (DashStyle)GetValue(DashStyleProperty); }
                set { SetValue(DashStyleProperty, value); }
            }
    
            private double FontSizeCallback
            {
                get { return (double)GetValue(FontSizeCallbackProperty); }
                set { SetValue(FontSizeCallbackProperty, value); }
            }
    
            public double TextLength
            {
                get { return (double)GetValue(TextLengthProperty); }
                set { SetValue(TextLengthProperty, value); }
            }
    
            public bool DashEnabled
            {
                get { return (bool)GetValue(DashEnabledProperty); }
                set { SetValue(DashEnabledProperty, value); }
            }
    
            private void DashTextBlock_TextChanged(object sender, TextChangedEventArgs e)
            {
                UpdateTextWithDashing();
            }
    
            private void FontSizeChanged()
            {
                //UpdateTextWithDashing();
            }
    
            private void DashEnabledChanged()
            {
                UpdateTextWithDashing();
            }
    
            private void DashColorChanged()
            {
                UpdateTextWithDashing();
            }
    
            private void DashStyleChanged()
            {
                UpdateTextWithDashing();
            }
    
            private void DashThicknessChanged()
            {
                UpdateTextWithDashing();
            }
    
            public void UpdateTextWithDashing()
            {
                AddDashDecoration();
                _dashDecoration.Pen = CreatePenFromProperties();
            }
    
            private Pen CreatePenFromProperties()
            {
                if (!DashEnabled)
                {
                    return _transparentPen;
                }
                if (DashStyle.Dashes.Count < 2 ||
                    IsLoaded == false ||
                    Text.Length == 0)
                {
                    return new Pen(new SolidColorBrush(DashColor), DashThickness);
                }
                double length = 0.0;
                foreach (var dash in DashStyle.Dashes)
                {
                    length += dash;
                }
                double stepLength = 1.0 / length;
    
                TextBox textBox = this as TextBox;
                Rect textRect = Rect.Empty;
                for (int l = (textBox.Text.Length - 1); l >= 0; l--)
                {
                    if (textBox.Text[l] != ' ')
                    {
                        try
                        {
                            textRect = textBox.GetRectFromCharacterIndex(l + 1);
                        }
                        catch
                        {
                            // See possible bug here:
                            // https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/VirtualizingStackPanel.cs,8060
                            // TODO: Revisit after migrate to .NET 5
                        }
                        break;
                    }
                }
                double target = FontSize;
                double availableWidth = textRect.Right;
                if (textRect.IsEmpty == false &&
                    availableWidth > 0)
                {
                    TextLength = availableWidth;
                    double current = 0;
                    bool count = true;
                    bool foundTargetLength = false;
                    double savedDashes = 0.0;
                    while (!foundTargetLength)
                    {
                        for (int i = 0; i < DashStyle.Dashes.Count; i++)
                        {
                            var dash = DashStyle.Dashes[i];
                            savedDashes += dash;
                            double increase = (target * (dash * stepLength));
                            double preDiff = availableWidth - current;
                            current += increase;
                            double postDiff = current - availableWidth;
                            if (current > availableWidth)
                            {
                                if (!count)
                                {
                                    if (postDiff < preDiff || Text.Length <= 2)
                                    {
                                        if ((i + 1) < DashStyle.Dashes.Count)
                                        {
                                            savedDashes += DashStyle.Dashes[i + 1];
                                        }
                                        else
                                        {
                                            savedDashes += DashStyle.Dashes[0];
                                        }
                                    }
                                    else
                                    {
                                        if (i == 0)
                                        {
                                            savedDashes -= DashStyle.Dashes.Last();
                                        }
                                        else
                                        {
                                            savedDashes -= DashStyle.Dashes[i - 1];
                                        }
                                    }
                                }
                                foundTargetLength = true;
                                target = availableWidth / (savedDashes * stepLength);
                                break;
                            }
                            count = !count;
                        }
                    }
                }
    
                LinearGradientBrush dashBrush = new LinearGradientBrush();
                dashBrush.StartPoint = new Point(0, 0);
                dashBrush.EndPoint = new Point(target, 0);
                dashBrush.MappingMode = BrushMappingMode.Absolute;
                dashBrush.SpreadMethod = GradientSpreadMethod.Repeat;
    
                double offset = 0.0;
                bool isFill = true;
                foreach (var dash in DashStyle.Dashes)
                {
                    GradientStop gradientStop = new GradientStop();
                    gradientStop.Offset = offset;
                    gradientStop.Color = isFill ? DashColor : Colors.Transparent;
                    dashBrush.GradientStops.Add(gradientStop);
    
                    offset += (dash * stepLength);
    
                    gradientStop = new GradientStop();
                    gradientStop.Offset = offset;
                    gradientStop.Color = isFill ? DashColor : Colors.Transparent;
                    dashBrush.GradientStops.Add(gradientStop);
    
                    isFill = !isFill;
                }
    
                Pen dashPen = new Pen(dashBrush, DashThickness);
                return dashPen;
            }
    
            private void AddDashDecoration()
            {
                foreach (TextDecoration textDecoration in TextDecorations)
                {
                    if (textDecoration == _dashDecoration)
                    {
                        return;
                    }
                }
                TextDecorations.Add(_dashDecoration);
            }
        }
    

    Style

        <Style TargetType="{x:Type controls:DashTextBlock}">
            <Setter Property="IsReadOnly" Value="True"/>
            <Setter Property="IsTabStop" Value="False"/>
            <Setter Property="Focusable" Value="False"/>
            <Setter Property="VerticalAlignment" Value="Center"/>
            <Setter Property="VerticalContentAlignment" Value="Center"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type controls:DashTextBlock}">
                        <Border x:Name="border"
                                BorderBrush="{TemplateBinding BorderBrush}"
                                BorderThickness="0"
                                Background="{TemplateBinding Background}"
                                SnapsToDevicePixels="True">
                            <ScrollViewer x:Name="PART_ContentHost"
                                            Focusable="False"
                                            HorizontalScrollBarVisibility="Hidden"
                                            VerticalScrollBarVisibility="Hidden"/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsEnabled" Value="False">
                                <Setter Property="Opacity" TargetName="border" Value="0.56"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>