WebForms RichTextBox Editing Approch?

177 Views Asked by At

I am using C# WinForm, and I have a RichTextBox that I am trying to make look like a C# script.

Means when using specific words, I want them to be colored. When they edit the word by changing it, I want it to go back to be black.

My approach works, but it really messy and cause bugs when the a scroll option is created and needed to be used to see the code below. (When typing, pretty much the richtextbox jumps up and down without stop)

private void ScriptRichTextBox_TextChanged(object sender, EventArgs e)
    {
        ScriptTextChange = ScriptRichTextBox.Text;
        ScriptColorChange();
    }

    private void ScriptColorChange()
    {
        int index = ScriptRichTextBox.SelectionStart;
        ScriptRichTextBox.Text = ScriptTextChange; //Only way I found to make the all current text black again, SelectAll() didn't work well.
        ScriptRichTextBox.SelectionStart = index;
        String[] coloredNames = {"Main", "ClickMouseDown", "ClickMouseUp", "PressKey", "StopMoving", "Delay", "GoRight", "GoLeft", "GoUp", "GoDown", "MousePosition", "LastHorizontalDirection", "LastVerticalDirections", "CurrentDirection", "Directions" };
        String[] coloredNames2 = { "cm.", "if", "else", "while", "switch", "case", "break", "return", "new" };
        String[] coloredNames3 = { "MyPosition", "MyHp", "MyMp", "OtherPeopleInMap", ".RIGHT", ".LEFT", ".UP", ".DOWN", ".STOP_MOVING" };
        foreach (String s in coloredNames)
            this.CheckKeyword(s, Color.LightSkyBlue, 0);
        foreach (String s in coloredNames2)
            this.CheckKeyword(s, Color.Blue, 0);
        foreach (String s in coloredNames3)
            this.CheckKeyword(s, Color.DarkGreen, 0);
    }

    private void CheckKeyword(string word, Color color, int startIndex)
    {
        if (this.ScriptRichTextBox.Text.Contains(word))
        {
            int index = 0;
            int selectStart = this.ScriptRichTextBox.SelectionStart;

            while ((index = this.ScriptRichTextBox.Text.IndexOf(word, (index + 1))) != -1)
            {
                this.ScriptRichTextBox.Select((index + startIndex), word.Length);
                this.ScriptRichTextBox.SelectionColor = color;
                this.ScriptRichTextBox.Select(selectStart, 0);
                this.ScriptRichTextBox.SelectionColor = Color.Black;
            }
        }
    }
2

There are 2 best solutions below

1
On BEST ANSWER

Ok after 2 days of not finding something that actually works good or has annoying bugs. I managed to find a solution myself after a big struggle of trying to make it work. The big idea is people try to edit all the RichTextBox words at once, which cause bugs. Why to edit all of the rich text box when you can do your checks on the current word only to get the same result. Which is what I did, I checked if any of my array strings is in the current word, and colored all of them.

private void ScriptRichTextBox_TextChanged(object sender, EventArgs e)
    {
        FindStringsInCurrentWord();
    }

    private void FindStringsInCurrentWord()
    {
        RichTextBox script = ScriptRichTextBox;
        String finalWord, forwards, backwards;
        int saveLastSelectionStart = script.SelectionStart;
        int index = script.SelectionStart;

        String[] coloredNames = { "Main", "ClickMouseDown", "ClickMouseUp", "PressKey", "StopMoving", "Delay", "GoRight", "GoLeft", "GoUp", "GoDown", "MousePosition", "LastHorizontalDirection", "LastVerticalDirections", "CurrentDirection", "Directions" };
        String[] coloredNames2 = { "cm.", "if", "else", "while", "switch", "case", "break", "return", "new" };
        String[] coloredNames3 = { "MyPosition", "MyHp", "MyMp", "OtherPeopleInMap", ".RIGHT", ".LEFT", ".UP", ".DOWN", ".STOP_MOVING" };

        String[] arr2 = coloredNames.Union(coloredNames2).ToArray();
        Array arrAll = arr2.Union(coloredNames3).ToArray(); //Gets all arrays together
        Array[] wordsArray = { coloredNames, coloredNames2, coloredNames3 }; //All found strings in the word
        List<String> wordsFoundList = new List<String>();
        int foundChangedColor = 0;
        int wordsFound = 0;


        char current = (char)script.GetCharFromPosition(script.GetPositionFromCharIndex(index)); //Where the editor thingy is
        //Check forward text where he uses space and save text
        while (!System.Char.IsWhiteSpace(current) && index < script.Text.Length)
        {
            index++;
            current = (char)script.GetCharFromPosition(script.GetPositionFromCharIndex(index));
        }
        int lengthForward = index - saveLastSelectionStart;
        script.Select(script.SelectionStart, lengthForward);
        forwards = script.SelectedText;
        //Debug.WriteLine("Forwards: " + forwards);
        script.SelectionStart = saveLastSelectionStart;
        this.ScriptRichTextBox.Select(script.SelectionStart, 0);
        index = script.SelectionStart;
        current = (char)script.GetCharFromPosition(script.GetPositionFromCharIndex(index));
        int length = 0;
        //Check backwords where he uses space and save text
        while ((!System.Char.IsWhiteSpace(current) || length == 0) && index > 0 && index <= script.Text.Length)
        {
            index--;
            length++;
            current = (char)script.GetCharFromPosition(script.GetPositionFromCharIndex(index));
        }
        script.SelectionStart -= length;
        script.Select(script.SelectionStart + 1, length - 1);
        backwards = script.SelectedText;
        //Debug.WriteLine("Backwards: " + backwards);
        script.SelectionStart = saveLastSelectionStart;
        this.ScriptRichTextBox.Select(saveLastSelectionStart, 0);
        this.ScriptRichTextBox.SelectionColor = Color.Black;
        finalWord = backwards + forwards; //Our all word!
        //Debug.WriteLine("WORD:" + finalWord);
  
        //Setting all of the word black, after it coloring the right places
        script.Select(index + 1, length + lengthForward);
        script.SelectionColor = Color.Black;
        foreach (string word in arrAll)
        {
            if (finalWord.IndexOf(word) != -1)
            {
                wordsFound++;
                wordsFoundList.Add(word);
                script.Select(index + 1 + finalWord.IndexOf(word), word.Length);
                if (coloredNames.Any(word.Contains))
                {
                    script.SelectionColor = Color.LightSkyBlue;
                    foundChangedColor++;
                }
                else if (coloredNames2.Any(word.Contains))
                {
                    script.SelectionColor = Color.Blue;
                    foundChangedColor++;
                }
                else if (coloredNames3.Any(word.Contains))
                {
                    script.SelectionColor = Color.DarkGreen;
                    foundChangedColor++;
                }
                //Debug.WriteLine("Word to edit: " + script.SelectedText);
                this.ScriptRichTextBox.Select(saveLastSelectionStart, 0);
                this.ScriptRichTextBox.SelectionColor = Color.Black;
            }
        }

        //No strings found, color it black
        if (wordsFound == 0)
        {
            script.Select(index + 1, length + lengthForward);
            script.SelectionColor = Color.Black;
            //Debug.WriteLine("WORD??: " + script.SelectedText);
            this.ScriptRichTextBox.Select(saveLastSelectionStart, 0);
            this.ScriptRichTextBox.SelectionColor = Color.Black;
        }
    }
3
On

I refactored your code a little to hopefully demonstrate a better approach to colouring the text. It is also not optimal to instantiate your string arrays every time you fire the TextChanged event.

Updated:The idea is to build up a word buffer that will be matched with your set of words when typing.

The buffer records each key and if it .IsLetterOrDigit it adds it to the StringBuilder buffer. The buffer has some additional bugs, with recording key press values and not removing recorded chars if you hit backspace etc..

Instead of the word buffer, use RegEx to match any of the words in your reserve word list. Build up the reserve word RegEx so you end up with something like \b(word|word2|word3....)\b This is done in the code in the BuildRegExPattern(..) method.

Once you hit any key other than a letter or number the buffer is checked for content and if the content matches a word then only the text right before the cursor in the ScriptRichTextBox.Text is checked and changed.

Remove the .(dots) from the reserve words as this just complicates the matching criteria. The RegEx in the built up patters will match the words exactly, so if you type something like FARRIGHT or cms the words will not partially change colour.

As an extra I also covered the paste process pressing Ctrl+V because it is a bit of a pain in WinForms and will probably happen quite often.

There are older questions eg. this one that cover the scrolling behaviour, where it shows how to interop by adding the [System.Runtime.InteropServices.DllImport("user32.dll")] attribute, but it can be done without it.

To prevent all the scroll jumping you can make use of the .DefWndProc(msg) method on the form. this question pointed me towards the WM_SETREDRAW property.

There is also this list of other properties that can be set.

The full implementation is this:

public partial class Form1 : Form
{
    private readonly string[] _skyBlueStrings;
    private readonly string[] _blueStrings;
    private readonly string[] _greenStrings;

    //for pasting
    bool _IsCtrl;
    bool _IsV;

    //value to fix the colour not setting first character after return key pressed
    int _returnIdxFix = 0;

    //regex patterns to use
    string _LightBlueRegX = "";
    string _BlueRegX = "";
    string _GreenRegX = "";

    //match only words
    Regex _rgxAnyWords = new Regex(@"(\w+)");

    //colour setup
    Color _LightBlueColour = Color.LightSkyBlue;
    Color _BlueColour = Color.Blue;
    Color _GreenColour = Color.DarkGreen;
    Color _DefaultColour = Color.Black;


    public Form1()
    {
        InitializeComponent();

        _skyBlueStrings = new string[] { "Main", "ClickMouseDown", "ClickMouseUp", "PressKey", "StopMoving", "Delay", "GoRight", "GoLeft", "GoUp", "GoDown", "MousePosition", "LastHorizontalDirection", "LastVerticalDirections", "CurrentDirection", "Directions" };
        _blueStrings = new string[] { "cm", "if", "else", "while", "switch", "case", "break", "return", "new" };
        _greenStrings = new string[] { "MyPosition", "MyHp", "MyMp", "OtherPeopleInMap", "RIGHT", "LEFT", "UP", "DOWN", "STOP_MOVING" };

        _LightBlueRegX = BuildRegExPattern(_skyBlueStrings);
        _BlueRegX = BuildRegExPattern(_blueStrings);
        _GreenRegX = BuildRegExPattern(_greenStrings);
    }

    string BuildRegExPattern(string[] keyworkArray)
    {
        StringBuilder _regExPatern = new StringBuilder();
        _regExPatern.Append(@"\b(");//beginning of word
        _regExPatern.Append(string.Join("|", keyworkArray));//all reserve words
        _regExPatern.Append(@")\b");//end of word
        return _regExPatern.ToString();
    }

    private void ProcessAllText()
    {
        BeginRtbUpdate();
        FormatKeywords(_LightBlueRegX, _LightBlueColour);
        FormatKeywords(_BlueRegX, _BlueColour);
        FormatKeywords(_GreenRegX, _GreenColour);

        //internal function to process words and set their colours
        void FormatKeywords(string regExPattern, Color wordColour)
        {
            var matchStrings = Regex.Matches(ScriptRichTextBox.Text, regExPattern);
            foreach (Match match in matchStrings)
            {
                FormatKeyword(keyword: match.Value, wordIndex: match.Index, wordColour: wordColour);
            }
        }

        EndRtbUpdate();
        ScriptRichTextBox.Select(ScriptRichTextBox.Text.Length, 0);
        ScriptRichTextBox.Invalidate();
    }

    void ProcessWordAtIndex(string fullText, int cursorIdx)
    {
        MatchCollection anyWordMatches = _rgxAnyWords.Matches(fullText);
        if (anyWordMatches.Count == 0)
        { return; } // no words found

        var allWords = anyWordMatches.OfType<Match>().ToList();

        //get the word just before cursor
        var wordAtCursor = allWords.FirstOrDefault(w => (cursorIdx - _returnIdxFix) == (w.Index + w.Length));
        if (wordAtCursor is null || string.IsNullOrWhiteSpace(wordAtCursor.Value))
        { return; }//no word at cursor or the match was blank

        Color wordColour = CalculateWordColour(wordAtCursor.Value);
        FormatKeyword(wordAtCursor.Value, wordAtCursor.Index, wordColour);

    }

    private Color CalculateWordColour(string word)
    {
        if (_skyBlueStrings.Contains(word))
        { return _LightBlueColour; }
        if (_blueStrings.Contains(word))
        { return _BlueColour; }
        if (_greenStrings.Contains(word))
        { return _GreenColour; }
        return _DefaultColour;
    }

    private void FormatKeyword(string keyword, int wordIndex, Color wordColour)
    {
        ScriptRichTextBox.Select((wordIndex - _returnIdxFix), keyword.Length);
        ScriptRichTextBox.SelectionColor = wordColour;
        ScriptRichTextBox.Select(wordIndex + keyword.Length, 0);
        ScriptRichTextBox.SelectionColor = _DefaultColour;
    }

    #region RichTextBox BeginUpdate and EndUpdate Methods
        protected override void WndProc(ref Message m)
        {
            base.WndProc(ref m);
            //wait until the rtb is visible, otherwise you get some weird behaviour.
            if (ScriptRichTextBox.Visible && ScriptRichTextBox.IsHandleCreated)
            {
                if (m.LParam == ScriptRichTextBox.Handle)
                {
                    rtBox_lParam = m.LParam;
                    rtBox_wParam = m.WParam;
                }
            }
        }

        IntPtr rtBox_wParam = IntPtr.Zero;
        IntPtr rtBox_lParam = IntPtr.Zero;
        const int WM_SETREDRAW = 0x0b;
        const int EM_HIDESELECTION = 0x43f;

        void BeginRtbUpdate()
        {
            Message msg_WM_SETREDRAW = Message.Create(ScriptRichTextBox.Handle, WM_SETREDRAW, (IntPtr)0, rtBox_lParam);
            this.DefWndProc(ref msg_WM_SETREDRAW);
        }

        public void EndRtbUpdate()
        {
            Message msg_WM_SETREDRAW = Message.Create(ScriptRichTextBox.Handle, WM_SETREDRAW, rtBox_wParam, rtBox_lParam);
            this.DefWndProc(ref msg_WM_SETREDRAW);
            //redraw the RichTextBox
            ScriptRichTextBox.Invalidate();
        }
        #endregion

    private void ScriptRichTextBox_TextChanged(object sender, EventArgs e)
    {
        //only run all text if it was pasted NOT ON EVERY TEXT CHANGE!
        if (_IsCtrl && _IsV)
        {
            _IsCtrl = false;
            ProcessAllText();
        }
    }

    protected void ScriptRichTextBox_KeyPress(object sender, KeyPressEventArgs e)
    {
        if (!char.IsLetterOrDigit(e.KeyChar))
        {
            //if the key was enter the cursor position is 1 position off                
            _returnIdxFix = (e.KeyChar == '\r') ? 1 : 0;
            ProcessWordAtIndex(ScriptRichTextBox.Text, ScriptRichTextBox.SelectionStart);
        }
    }

    private void ScriptRichTextBox_KeyDown(object sender, System.Windows.Forms.KeyEventArgs e)
    {
        if (e.KeyCode == Keys.ControlKey)
        {
            _IsCtrl = true;
        }
        if (e.KeyCode == Keys.V)
        {
            _IsV = true;
        }
    }

    private void ScriptRichTextBox_KeyUp(object sender, System.Windows.Forms.KeyEventArgs e)
    {
        if (e.KeyCode == Keys.ControlKey)
        {
            _IsCtrl = false;
        }
        if (e.KeyCode == Keys.V)
        {
            _IsV = false;
        }
    }
}

It looks like this when you paste some "code" with keywords:

paste some text

and typing looks like this:

type some text