5

I've been tasked with creating a partially editable RichTextBox. I've seen suggestions in Xaml adding TextBlock elements for the ReadOnly sections, however this has the undesireable visual effect of not wrapping nicely. (It should appear as a single block of continuous text.)

I've patched together a working prototype using some reverse string formatting to restrict/allow edits and coupled that with dynamic creation of inline Run elements for displaying purposes. Using a dictionary to store the current values of the editable sections of text, I update the Run elements accordingly upon any TextChanged event trigger - with the idea that if an editable section's text is completely deleted, it will be replaced back to its default value.

In the string: "Hi NAME, welcome to SPORT camp.", only NAME and SPORT are editable.

                ╔═══════╦════════╗                    ╔═══════╦════════╗
Default values: ║ Key   ║ Value  ║    Edited values:  ║ Key   ║ Value  ║
                ╠═══════╬════════╣                    ╠═══════╬════════╣
                ║ NAME  ║ NAME   ║                    ║ NAME  ║ John   ║
                ║ SPORT ║ SPORT  ║                    ║ SPORT ║ Tennis ║
                ╚═══════╩════════╝                    ╚═══════╩════════╝

 "Hi NAME, welcome to SPORT camp."    "Hi John, welcome to Tennis camp."

Problem:

Deleting the entire text value in a particular run removes that run (and the following run) from the RichTextBox Document. Even though I add them all back, they no longer display correctly on screen. For example, using the edited string from the above setup:

  • User highlights the text "John" and clicks Delete, instead of saving the empty value, it should be replaced with the default text of "NAME". Internally this happens. The dictionary gets the correct value, the Run.Text has the value, the Document contains all the correct Run elements. But the screen displays:

    • Expected: "Hi NAME, welcome to Tennis camp."
    • Actual: "Hi NAMETennis camp."

Actual vs Expected screenshot

Sidenote: This loss-of-Run-element behavior can also be duplicated when pasting. Highlight "SPORT" and paste "Tennis" and the Run containing " camp." is lost.

Question:

How do I keep every Run element visible even through destructive actions, once they've been replaced?

Code:

I have tried to strip down the code to a minimum example, so I've removed:

  • Every DependencyProperty and associated binding in the xaml
  • Logic recalculating caret position (sorry)
  • Refactored the linked string formatting extension methods from the first link to a single method contained within the class. (Note: this method will work for simple example string formats. My code for more robust formatting has been excluded. So please stick to the example provided for these testing purposes.)
  • Made the editable sections clearly visible, nevermind the color scheme.

To test, drop the class into your WPF project Resource folder, fix the namespace, and add the control to a View.

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;

namespace WPFTest.Resources
{
  public class MyRichTextBox : RichTextBox
  {
    public MyRichTextBox()
    {
      this.TextChanged += MyRichTextBox_TextChanged;
      this.Background = Brushes.LightGray;

      this.Parameters = new Dictionary<string, string>();
      this.Parameters.Add("NAME", "NAME");
      this.Parameters.Add("SPORT", "SPORT");

      this.Format = "Hi {0}, welcome to {1} camp.";
      this.Text = string.Format(this.Format, this.Parameters.Values.ToArray<string>());

      this.Runs = new List<Run>()
      {
        new Run() { Background = Brushes.LightGray, Tag = "Hi " },
        new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "NAME" },
        new Run() { Background = Brushes.LightGray, Tag = ", welcome to " },
        new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "SPORT" },
        new Run() { Background = Brushes.LightGray, Tag = " camp." },
      };

      this.UpdateRuns();
    }

    public Dictionary<string, string> Parameters { get; set; }
    public List<Run> Runs { get; set; }
    public string Text { get; set; }
    public string Format { get; set; }

    private void MyRichTextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
      string richText = new TextRange(this.Document.Blocks.FirstBlock.ContentStart, this.Document.Blocks.FirstBlock.ContentEnd).Text;
      string[] oldValues = this.Parameters.Values.ToArray<string>();
      string[] newValues = null;

      bool extracted = this.TryParseExact(richText, this.Format, out newValues);

      if (extracted)
      {
        var changed = newValues.Select((x, i) => new { NewVal = x, Index = i }).Where(x => x.NewVal != oldValues[x.Index]).FirstOrDefault();
        string key = this.Parameters.Keys.ElementAt(changed.Index);
        this.Parameters[key] = string.IsNullOrWhiteSpace(newValues[changed.Index]) ? key : newValues[changed.Index];

        this.Text = richText;
      }
      else
      {
        e.Handled = true;
      }

      this.UpdateRuns();
    }

    private void UpdateRuns()
    {
      this.TextChanged -= this.MyRichTextBox_TextChanged;

      foreach (Run run in this.Runs)
      {
        string value = run.Tag.ToString();

        if (this.Parameters.ContainsKey(value))
        {
          run.Text = this.Parameters[value];
        }
        else
        {
          run.Text = value;
        }
      }

      Paragraph p = this.Document.Blocks.FirstBlock as Paragraph;
      p.Inlines.Clear();
      p.Inlines.AddRange(this.Runs);

      this.TextChanged += this.MyRichTextBox_TextChanged;
    }

    public bool TryParseExact(string data, string format, out string[] values)
    {
      int tokenCount = 0;
      format = Regex.Escape(format).Replace("\\{", "{");
      format = string.Format("^{0}$", format);

      while (true)
      {
        string token = string.Format("{{{0}}}", tokenCount);

        if (!format.Contains(token))
        {
          break;
        }

        format = format.Replace(token, string.Format("(?'group{0}'.*)", tokenCount++));
      }

      RegexOptions options = RegexOptions.None;

      Match match = new Regex(format, options).Match(data);

      if (tokenCount != (match.Groups.Count - 1))
      {
        values = new string[] { };
        return false;
      }
      else
      {
        values = new string[tokenCount];

        for (int index = 0; index < tokenCount; index++)
        {
          values[index] = match.Groups[string.Format("group{0}", index)].Value;
        }

        return true;
      }
    }
  }
}
Community
  • 1
  • 1
OhBeWise
  • 5,350
  • 3
  • 32
  • 60
  • `RichTextBox` provides much more than a mere text editor through the `FlowDocument` class. This makes it a handy control for displaying formatted text, images, tables, and even `UIElements`. However all these come with the cost of complexity. Therefore, dealing with `RichTextBox` can be rather messy. Is there any chance you can switch to a more convenient control like `AvalonEdit` which is better suited for text processing? – Yusuf Tarık Günaydın Jul 08 '16 at 20:00
  • From what I can see the problem is that `TryParseExact(richText` fails. Is it reasonable to start fixing it? –  Jul 08 '16 at 20:02
  • @qqww2 I'm not familiar with `AvalonEdit` or it's licensing requirements, but I will consider it. – OhBeWise Jul 08 '16 at 20:04
  • @OhBeWise Sorry, maybe I'm wrong or I'm misunderstanding your requirements. I'm trying to run your code and I see that - if I delete "SPORT" (for example) - then `bool extracted` is false... I would go on fixing this, if it makes sense... –  Jul 08 '16 at 20:10
  • @MachineLearning Ah, you are probably dbl-click highlighting so when you try to delete "SPORT" it detects you trying to delete "SPORT " with the space - and the space isn't editable. I've actually overridden the highlighting behavior because of this error. I just didn't include it in this example - so sorry about that. – OhBeWise Jul 08 '16 at 20:12

2 Answers2

2

The problem with your code is that when you change the text via user interface, internal Run objects are modified, created, deleted, and all the crazy things occur behind the scenes. The internal structure is very complex. For example here is a method which is called deep inside the innocent single line p.Inlines.Clear();:

private int DeleteContentFromSiblingTree(SplayTreeNode containingNode, TextPointer startPosition, TextPointer endPosition, bool newFirstIMEVisibleNode, out int charCount)
{
    SplayTreeNode leftSubTree;
    SplayTreeNode middleSubTree;
    SplayTreeNode rightSubTree;
    SplayTreeNode rootNode;
    TextTreeNode previousNode;
    ElementEdge previousEdge;
    TextTreeNode nextNode;
    ElementEdge nextEdge;
    int symbolCount;
    int symbolOffset;

    // Early out in the no-op case. CutContent can't handle an empty content span.
    if (startPosition.CompareTo(endPosition) == 0)
    {
        if (newFirstIMEVisibleNode)
        {
            UpdateContainerSymbolCount(containingNode, /* symbolCount */ 0, /* charCount */ -1);
        }
        charCount = 0;
        return 0;
    }

    // Get the symbol offset now before the CutContent call invalidates startPosition.
    symbolOffset = startPosition.GetSymbolOffset();

    // Do the cut.  middleSubTree is what we want to remove.
    symbolCount = CutContent(startPosition, endPosition, out charCount, out leftSubTree, out middleSubTree, out rightSubTree);

    // We need to remember the original previous/next node for the span
    // we're about to drop, so any orphaned positions can find their way
    // back.
    if (middleSubTree != null)
    {
        if (leftSubTree != null)
        {
            previousNode = (TextTreeNode)leftSubTree.GetMaxSibling();
            previousEdge = ElementEdge.AfterEnd;
        }
        else
        {
            previousNode = (TextTreeNode)containingNode;
            previousEdge = ElementEdge.AfterStart;
        }
        if (rightSubTree != null)
        {
            nextNode = (TextTreeNode)rightSubTree.GetMinSibling();
            nextEdge = ElementEdge.BeforeStart;
        }
        else
        {
            nextNode = (TextTreeNode)containingNode;
            nextEdge = ElementEdge.BeforeEnd;
        }

        // Increment previous/nextNode reference counts. This may involve
        // splitting a text node, so we use refs.
        AdjustRefCountsForContentDelete(ref previousNode, previousEdge, ref nextNode, nextEdge, (TextTreeNode)middleSubTree);

        // Make sure left/rightSubTree stay local roots, we might
        // have inserted new elements in the AdjustRefCountsForContentDelete call.
        if (leftSubTree != null)
        {
            leftSubTree.Splay();
        }
        if (rightSubTree != null)
        {
            rightSubTree.Splay();
        }
        // Similarly, middleSubtree might not be a local root any more,
        // so splay it too.
        middleSubTree.Splay();

        // Note TextContainer now has no references to middleSubTree, if there are
        // no orphaned positions this allocation won't be kept around.
        Invariant.Assert(middleSubTree.ParentNode == null, "Assigning fixup node to parented child!");
        middleSubTree.ParentNode = new TextTreeFixupNode(previousNode, previousEdge, nextNode, nextEdge);
    }

    // Put left/right sub trees back into the TextContainer.
    rootNode = TextTreeNode.Join(leftSubTree, rightSubTree);
    containingNode.ContainedNode = rootNode;
    if (rootNode != null)
    {
        rootNode.ParentNode = containingNode;
    }

    if (symbolCount > 0)
    {
        int nextNodeCharDelta = 0;
        if (newFirstIMEVisibleNode)
        {
            // The following node is the new first ime visible sibling.
            // It just moved, and loses an edge character.
            nextNodeCharDelta = -1;
        }

        UpdateContainerSymbolCount(containingNode, -symbolCount, -charCount + nextNodeCharDelta);
        TextTreeText.RemoveText(_rootNode.RootTextBlock, symbolOffset, symbolCount);
        NextGeneration(true /* deletedContent */);

        // Notify the TextElement of a content change. Note that any full TextElements
        // between startPosition and endPosition will be handled by CutTopLevelLogicalNodes,
        // which will move them from this tree to their own private trees without changing
        // their contents.
        Invariant.Assert(startPosition.Parent == endPosition.Parent);
        TextElement textElement = startPosition.Parent as TextElement;
        if (textElement != null)
        {               
            textElement.OnTextUpdated();                    
        }
    }

    return symbolCount;
}

You can look at the source code from here if you are interested.

A solution is do not use the Run objects you created for comparison purposes directly in the FlowDocument. Always make a copy of it before adding them:

private void UpdateRuns()
{
    TextChanged -= MyRichTextBox_TextChanged;

    List<Run> runs = new List<Run>();
    foreach (Run run in Runs)
    {
        Run newRun;
        string value = run.Tag.ToString();

        if (Parameters.ContainsKey(value))
        {
            newRun = new Run(Parameters[value]);
        }
        else
        {
            newRun = new Run(value);
        }

        newRun.Background = run.Background;
        newRun.Foreground = run.Foreground;

        runs.Add(newRun);
    }

    Paragraph p = Document.Blocks.FirstBlock as Paragraph;
    p.Inlines.Clear();
    p.Inlines.AddRange(runs);

    TextChanged += MyRichTextBox_TextChanged;
}
Yusuf Tarık Günaydın
  • 3,016
  • 2
  • 27
  • 41
  • This is almost exactly what I did to solve this but I didn't really understand *why* it was a solution, but I suspected something under the hood was *remembering* the re-added Run was deleted. I couldn't agree more with "*crazy things occur behind the scenes*." Trying to fix the caret positioning for instance... algorithmic nightmare which I'm trying to simplify - so many edge cases. Thanks for the link to the source. – OhBeWise Jul 08 '16 at 21:48
1

I would suggest to move the code to create the Runs in the UpdateRuns

    private void UpdateRuns()
    {
        this.TextChanged -= this.MyRichTextBox_TextChanged;

        this.Runs = new List<Run>()
  {
    new Run() { Background = Brushes.LightGray, Tag = "Hi " },
    new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "NAME" },
    new Run() { Background = Brushes.LightGray, Tag = ", welcome to " },
    new Run() { Background = Brushes.Black, Foreground = Brushes.White, Tag = "SPORT" },
    new Run() { Background = Brushes.LightGray, Tag = " camp." },
  };

        foreach (Run run in this.Runs)
  • I appreciate your answer. It was similar to what I had done and qqww2 had reaffirmed for me. The other approach was better suited outside this simple example when bindings and `DependencyProperty` were the data sources and it helped provide more of *the why* I needed. Still, have a +1 for your troubles and my thanks! – OhBeWise Jul 08 '16 at 21:51