Search code examples
c#wpfrichtextboxspell-checking

WPF RichTextBox - Setting Caret.RenderTransform Breaks Spell Check


I've got a C# WPF RichTextBox that allows for ScaleX and ScaleY LayoutTransform adjustments via a Slider. Unfortunately, this scaling can cause the caret to stop rendering, a bug that can be fixed per the code at this SO post here. More unfortunately, setting the caret's RenderTransform causes the spell check red squiggly lines to stop showing up as you type. It seems as though unfocusing the RichTextBox and focusing it again by clicking on the Slider will cause all of the red squiggly lines to reappear. You can view a demo of this bug on my GitHub here.

GIF of bug

Question: How can I cause the red squiggly spell check lines to show up as the user types while still allowing for RichTextBox scaling and a fully-rendered-at-all-scale-levels caret? I've tried manually calling GetSpellingError(TextPointer), and this works...sort of. It isn't fully reliable unless I call GetSpellingError on every word of the RichTextBox, which is very slow to calculate when there is a large amount of content. I've also attempted to use reflection and such on items within the Speller and related internal classes, such as Highlights, SpellerStatusTable, and SpellerHighlightLayer. When looking at SpellerStatusTable's runs list (which appear to have info on if runs are clean or dirty), the runs are not updated to contain errors until the slider is clicked, which implies that the RichTextBox is not re-checking for spelling errors.

Commenting out caretSubElement.RenderTransform = scaleTransform; in CustomRichTextBox.cs "fixes" the problem but then breaks the caret rendering again.

Code --

MainWindow.xaml:

<Window x:Class="BrokenRichTextBox.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:BrokenRichTextBox"
        mc:Ignorable="d"
        Title="Rich Text Box Testing" Height="350" Width="525">
    <Grid Background="LightGray">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>
        <Slider Name="FontZoomSlider" Grid.Row="0" Width="150" Value="2" Minimum="0.3" Maximum="10" HorizontalAlignment="Right" VerticalAlignment="Center"/>
        <local:CustomRichTextBox x:Name="richTextBox" 
                                 Grid.Row="1" 
                                 SpellCheck.IsEnabled="True"
                                 ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                 ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                 AcceptsTab="True">
            <local:CustomRichTextBox.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=richTextBox, Path=ScaleX, Mode=TwoWay}" 
                                ScaleY="{Binding ElementName=richTextBox, Path=ScaleY, Mode=TwoWay}"/>
            </local:CustomRichTextBox.LayoutTransform>
            <FlowDocument>
                <Paragraph>
                    <Run>I am some sample text withhh typooos</Run>
                </Paragraph>
                <Paragraph>
                    <Run FontStyle="Italic">I am some more sample text in italic</Run>
                </Paragraph>
            </FlowDocument>
        </local:CustomRichTextBox>
    </Grid>
</Window>

CustomRichTextBox.cs:

using System;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;

namespace BrokenRichTextBox
{
    class CustomRichTextBox : RichTextBox
    {
        private bool _didAddLayoutUpdatedEvent = false;

        public CustomRichTextBox() : base()
        {
            UpdateAdorner();
            if (!_didAddLayoutUpdatedEvent)
            {
                _didAddLayoutUpdatedEvent = true;
                LayoutUpdated += updateAdorner;
            }
        }

        public void UpdateAdorner()
        {
            updateAdorner(null, null);
        }

        // Fixing missing caret bug code adjusted from: https://stackoverflow.com/questions/5180585/viewbox-makes-richtextbox-lose-its-caret
        private void updateAdorner(object sender, EventArgs e)
        {
            Dispatcher.BeginInvoke(new Action(() =>
            {
                Selection.GetType().GetMethod("System.Windows.Documents.ITextSelection.UpdateCaretAndHighlight", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(
                    Selection, null);
                var caretElement = Selection.GetType().GetProperty("CaretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(Selection, null);
                if (caretElement == null)
                    return;
                var caretSubElement = caretElement.GetType().GetField("_caretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement) as UIElement;
                if (caretSubElement == null) return;
                // Scale slightly differently if in italic just so it looks a little bit nicer
                bool isItalic = (bool)caretElement.GetType().GetField("_italic", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement);
                double scaleX = 1;
                if (!isItalic)
                    scaleX = (1 / ScaleX);
                else
                    scaleX = 0.685;// output;
                double scaleY = 1;
                var scaleTransform = new ScaleTransform(scaleX, scaleY);
                caretSubElement.RenderTransform = scaleTransform; // The line of trouble
            }), DispatcherPriority.ContextIdle);
        }

        public double ScaleX
        {
            get { return (double)GetValue(ScaleXProperty); }
            set { SetValue(ScaleXProperty, value); }
        }
        public static readonly DependencyProperty ScaleXProperty =
            DependencyProperty.Register("ScaleX", typeof(double), typeof(CustomRichTextBox), new UIPropertyMetadata(1.0));

        public double ScaleY
        {
            get { return (double)GetValue(ScaleYProperty); }
            set { SetValue(ScaleYProperty, value); }
        }
        public static readonly DependencyProperty ScaleYProperty =
            DependencyProperty.Register("ScaleY", typeof(double), typeof(CustomRichTextBox), new UIPropertyMetadata(1.0));

    }
}

Solution

  • I managed to get things working, at least by appearances. The tl;dr fix is to do manual GetSpellingError calls on the previous/next word as well as the first and last words of the previous/next Paragraphs (Blocks). Just checking surrounding words didn't work, as for some strange reason, if I hit 'enter/return' at the end of the line AND the last word of that paragraph is spelled incorrectly, the spell checker didn't kick in. If the first word of the previous paragraph was spelled incorrectly after hitting 'enter/return', the red squiggle would disappear! In any case, manually checking words, but not checking all of the words, seems to work OK.

    My personal project has some extra "please check spelling on surrounding words" calls for some OnPreviewKeyDown instances in case UpdateAdorner wasn't called in time, but I'll leave that as an exercise to the reader. :)

    I'm guessing there are better answers somewhere.

    Code (Easily viewed on Github here):

    MainWindow.xaml:

    <Window x:Class="BrokenRichTextBox.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:BrokenRichTextBox"
            mc:Ignorable="d"
            Title="Rich Text Box Testing" Height="480" Width="640">
        <Grid Background="LightGray">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"></RowDefinition>
                <RowDefinition Height="*"></RowDefinition>
                <RowDefinition Height="Auto"></RowDefinition>
                <RowDefinition Height="*"></RowDefinition>
            </Grid.RowDefinitions>
            <!--CheckBox Content="Enable Extra" Grid.Row="0" VerticalAlignment="Center"/-->
            <Label Content="Broken RichTextBox" Grid.Row="0"/>
            <Slider Name="FontZoomSlider" Grid.Row="0" Width="150" Value="2" Minimum="0.3" Maximum="10" HorizontalAlignment="Right" VerticalAlignment="Center"/>
            <local:CustomRichTextBox x:Name="RichTextBox" 
                                     Grid.Row="1" 
                                     SpellCheck.IsEnabled="True"
                                     ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                     ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                     AcceptsTab="True">
                <local:CustomRichTextBox.LayoutTransform>
                    <ScaleTransform ScaleX="{Binding ElementName=RichTextBox, Path=ScaleX, Mode=TwoWay}" 
                                    ScaleY="{Binding ElementName=RichTextBox, Path=ScaleY, Mode=TwoWay}"/>
                </local:CustomRichTextBox.LayoutTransform>
                <FlowDocument>
                    <Paragraph>
                        <Run>I am some sample text withhh typooos</Run>
                    </Paragraph>
                    <Paragraph>
                        <Run FontStyle="Italic">I am some more sample text in italic</Run>
                    </Paragraph>
                </FlowDocument>
            </local:CustomRichTextBox>
            <Label Content="Better/Fixed RichTextBox" Grid.Row="2"/>
            <local:FixedCustomRichTextBox x:Name="FixedRichTextBox" 
                                     Grid.Row="3" 
                                     SpellCheck.IsEnabled="True"
                                     ScaleX="{Binding ElementName=FontZoomSlider, Path=Value}" 
                                     ScaleY="{Binding ElementName=FontZoomSlider, Path=Value}"
                                     AcceptsTab="True">
                <local:FixedCustomRichTextBox.LayoutTransform>
                    <ScaleTransform ScaleX="{Binding ElementName=FixedRichTextBox, Path=ScaleX, Mode=TwoWay}" 
                                    ScaleY="{Binding ElementName=FixedRichTextBox, Path=ScaleY, Mode=TwoWay}"/>
                </local:FixedCustomRichTextBox.LayoutTransform>
                <FlowDocument>
                    <Paragraph>
                        <Run>I am some sample text withhh typooos</Run>
                    </Paragraph>
                    <Paragraph>
                        <Run FontStyle="Italic">I am some more sample text in italic</Run>
                    </Paragraph>
                </FlowDocument>
            </local:FixedCustomRichTextBox>
        </Grid>
    </Window>
    

    FixedCustomRichTextBox.cs:

    using System;
    using System.Reflection;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Documents;
    using System.Windows.Media;
    using System.Windows.Threading;
    
    namespace BrokenRichTextBox
    {
        class FixedCustomRichTextBox : RichTextBox
        {
            private bool _didAddLayoutUpdatedEvent = false;
    
            public FixedCustomRichTextBox() : base()
            {
                UpdateAdorner();
                if (!_didAddLayoutUpdatedEvent)
                {
                    _didAddLayoutUpdatedEvent = true;
                    LayoutUpdated += updateAdorner;
                }
            }
    
            public void UpdateAdorner()
            {
                updateAdorner(null, null);
            }
    
            // Fixing missing caret bug code adjusted from: http://stackoverflow.com/questions/5180585/viewbox-makes-richtextbox-lose-its-caret
            private void updateAdorner(object sender, EventArgs e)
            {
                Dispatcher.BeginInvoke(new Action(() =>
                {
                    Selection.GetType().GetMethod("System.Windows.Documents.ITextSelection.UpdateCaretAndHighlight", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(
                        Selection, null);
                    var caretElement = Selection.GetType().GetProperty("CaretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(Selection, null);
                    if (caretElement == null)
                        return;
                    var caretSubElement = caretElement.GetType().GetField("_caretElement", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement) as UIElement;
                    if (caretSubElement == null) return;
                    // Scale slightly differently if in italic just so it looks a little bit nicer
                    bool isItalic = (bool)caretElement.GetType().GetField("_italic", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(caretElement);
                    double scaleX = 1;
                    if (!isItalic)
                        scaleX = (1 / ScaleX);
                    else
                        scaleX = 0.685;// output;
                    double scaleY = 1;
                    var scaleTransform = new ScaleTransform(scaleX, scaleY);
                    caretSubElement.RenderTransform = scaleTransform; // The line of trouble
                    updateSpellingErrors(CaretPosition);
                }), DispatcherPriority.ContextIdle);
            }
    
            private void checkSpelling(TextPointer pointer, string currentWord)
            {
                if (pointer != null)
                {
                    string otherText = WordBreaker.GetWordRange(pointer).Text;
                    if (currentWord != otherText || currentWord == "" || otherText == "")
                    {
                        GetSpellingError(pointer);
                    }
                }
            }
    
            private void checkSpelling(Paragraph paragraph, string currentWord)
            {
                if (paragraph != null)
                {
                    checkSpelling(paragraph.ContentStart.GetPositionAtOffset(3, LogicalDirection.Forward), currentWord);
                    checkSpelling(paragraph.ContentEnd.GetPositionAtOffset(-3, LogicalDirection.Backward), currentWord);
                }
            }
    
            private void updateSpellingErrors(TextPointer position)
            {
                string currentWord = GetCurrentWord();
    
                // Update first and last words of previous and next paragraphs
                var previousParagraph = position.Paragraph?.PreviousBlock as Paragraph;
                checkSpelling(previousParagraph, currentWord);
                var nextParagraph = position.Paragraph?.NextBlock as Paragraph;
                checkSpelling(nextParagraph, currentWord);
    
                // Update surrounding words next to current caret
                checkSpelling(position.GetPositionAtOffset(-3), currentWord);
                checkSpelling(position.GetPositionAtOffset(3), currentWord);
            }
    
            // Modified from: http://stackoverflow.com/a/26689916/3938401
            private string GetCurrentWord()
            {
                TextPointer start = CaretPosition;  // this is the variable we will advance to the left until a non-letter character is found
                TextPointer end = CaretPosition;    // this is the variable we will advance to the right until a non-letter character is found
                string stringBeforeCaret = start.GetTextInRun(LogicalDirection.Backward);   // extract the text in the current run from the caret to the left
                string stringAfterCaret = start.GetTextInRun(LogicalDirection.Forward);     // extract the text in the current run from the caret to the left
                int countToMoveLeft = 0;  // we record how many positions we move to the left until a non-letter character is found
                int countToMoveRight = 0; // we record how many positions we move to the right until a non-letter character is found
                for (int i = stringBeforeCaret.Length - 1; i >= 0; --i)
                {
                    // if the character at the location CaretPosition-LeftOffset is a letter, we move more to the left
                    if (!char.IsWhiteSpace(stringBeforeCaret[i]))
                        ++countToMoveLeft;
                    else break; // otherwise we have found the beginning of the word
                }
                for (int i = 0; i < stringAfterCaret.Length; ++i)
                {
                    // if the character at the location CaretPosition+RightOffset is a letter, we move more to the right
                    if (!char.IsWhiteSpace(stringAfterCaret[i]))
                        ++countToMoveRight;
                    else break; // otherwise we have found the end of the word
                }
                start = start.GetPositionAtOffset(-countToMoveLeft);    // modify the start pointer by the offset we have calculated
                end = end.GetPositionAtOffset(countToMoveRight);        // modify the end pointer by the offset we have calculated
                // extract the text between those two pointers
                TextRange r = new TextRange(start, end);
                string text = r.Text;
                // check the result
                return text;
            }
    
            public double ScaleX
            {
                get { return (double)GetValue(ScaleXProperty); }
                set { SetValue(ScaleXProperty, value); }
            }
            public static readonly DependencyProperty ScaleXProperty =
                DependencyProperty.Register("ScaleX", typeof(double), typeof(FixedCustomRichTextBox), new UIPropertyMetadata(1.0));
    
            public double ScaleY
            {
                get { return (double)GetValue(ScaleYProperty); }
                set { SetValue(ScaleYProperty, value); }
            }
            public static readonly DependencyProperty ScaleYProperty =
                DependencyProperty.Register("ScaleY", typeof(double), typeof(FixedCustomRichTextBox), new UIPropertyMetadata(1.0));
    
        }
    }
    

    WordBreaker.cs (From MSDN):

    using System.Windows.Documents;
    
    namespace BrokenRichTextBox
    {
        // https://blogs.msdn.microsoft.com/prajakta/2006/11/01/navigate-words-in-richtextbox/
        public static class WordBreaker
        {
            /// <summary>
            /// Returns a TextRange covering a word containing or following this TextPointer.
            /// </summary>
            /// <remarks>
            /// If this TextPointer is within a word or at start of word, the containing word range is returned.
            /// If this TextPointer is between two words, the following word range is returned.
            /// If this TextPointer is at trailing word boundary, the following word range is returned.
            /// </remarks>
            public static TextRange GetWordRange(TextPointer position)
            {
                TextRange wordRange = null;
                TextPointer wordStartPosition = null;
                TextPointer wordEndPosition = null;
                // Go forward first, to find word end position.
                wordEndPosition = GetPositionAtWordBoundary(position, /*wordBreakDirection*/LogicalDirection.Forward);
                if (wordEndPosition != null)
                {
                    // Then travel backwards, to find word start position.
                    wordStartPosition = GetPositionAtWordBoundary(wordEndPosition, /*wordBreakDirection*/LogicalDirection.Backward);
                }
                if (wordStartPosition != null && wordEndPosition != null)
                {
                    wordRange = new TextRange(wordStartPosition, wordEndPosition);
                }
                return wordRange;
            }
    
            /// <summary>
            /// 1.  When wordBreakDirection = Forward, returns a position at the end of the word,
            ///     i.e. a position with a wordBreak character (space) following it.
            /// 2.  When wordBreakDirection = Backward, returns a position at the start of the word,
            ///     i.e. a position with a wordBreak character (space) preceeding it.
            /// 3.  Returns null when there is no workbreak in the requested direction.
            /// </summary>
            private static TextPointer GetPositionAtWordBoundary(TextPointer position, LogicalDirection wordBreakDirection)
            {
                if (!position.IsAtInsertionPosition)
                {
                    position = position.GetInsertionPosition(wordBreakDirection);
                }
                TextPointer navigator = position;
                while (navigator != null && !IsPositionNextToWordBreak(navigator, wordBreakDirection))
                {
                    navigator = navigator.GetNextInsertionPosition(wordBreakDirection);
                }
                return navigator;
            }
            // Helper for GetPositionAtWordBoundary.
            // Returns true when passed TextPointer is next to a wordBreak in requested direction.
            private static bool IsPositionNextToWordBreak(TextPointer position, LogicalDirection wordBreakDirection)
            {
                bool isAtWordBoundary = false;
                // Skip over any formatting.
                if (position.GetPointerContext(wordBreakDirection) != TextPointerContext.Text)
                {
                    position = position.GetInsertionPosition(wordBreakDirection);
                }
                if (position.GetPointerContext(wordBreakDirection) == TextPointerContext.Text)
                {
                    LogicalDirection oppositeDirection = (wordBreakDirection == LogicalDirection.Forward) ?
                        LogicalDirection.Backward : LogicalDirection.Forward;
                    char[] runBuffer = new char[1];
                    char[] oppositeRunBuffer = new char[1];
                    position.GetTextInRun(wordBreakDirection, runBuffer, /*startIndex*/0, /*count*/1);
                    position.GetTextInRun(oppositeDirection, oppositeRunBuffer, /*startIndex*/0, /*count*/1);
                    if (runBuffer[0] == ' ' && !(oppositeRunBuffer[0] == ' '))
                    {
                        isAtWordBoundary = true;
                    }
                }
                else
                {
                    // If we’re not adjacent to text then we always want to consider this position a “word break”. 
                    // In practice, we’re most likely next to an embedded object or a block boundary.
                    isAtWordBoundary = true;
                }
                return isAtWordBoundary;
            }
        }
    }
    

    CustomRichTextBox.cs remains the same.