20

I'll be feeding a number of strings into labels on a Windows Form (I don't use these a lot). The strings will be similar to the following:

"The quick brown fox j___ed over the l__y hound"

I want to display the string in a label but overlay a TextBox exactly where the missing letters are.

There'll be 300+ strings, and I'm looking for the simplest, most elegant way to do it.

How do I reposition the textbox accurately for each string?

EDIT: A MaskTextBox won't work as I need multiline support.

Dhaval Asodariya
  • 558
  • 5
  • 19
Galwegian
  • 41,475
  • 16
  • 112
  • 158
  • 3
    You maybe be better off just appending a couple of controls together, instead of worrying about measuring strings and position the textbox – TheGeneral Jan 23 '18 at 12:34
  • 4
    Why not use a richtextbox? https://stackoverflow.com/questions/5910923/disabling-or-making-it-readonly-a-part-of-the-values-in-a-text-box-net – MatSnow Jan 23 '18 at 12:38
  • 1
    Otherwise Graphics.MeasureString is you friend (or enemy) as it can be bit fickle – TheGeneral Jan 23 '18 at 12:38
  • The left location of a text box is in pixels. So you need to get the equivalent pixels of the text. See msdn : https://msdn.microsoft.com/en-us/library/system.drawing.graphics.measurestring(VS.80).aspx – jdweng Jan 23 '18 at 12:39
  • Your idea is probably doomed. The kind of precision and predictability required for that sort of positioning is not going to happen reliably across many systems. I would seriously consider the alternatives suggested. – DonBoitnott Jan 23 '18 at 13:08
  • 1
    Does it have to be [tag:winforms]? Could you use [tag:wpf] instead? – dbc Jan 30 '18 at 18:29
  • @SteliosAdamantidis - I'm not a winforms person - I'm looking for someone who uses winforms every day who can tell me the best and quickest way to get this done. Have you any suggestions? Thanks! – Galwegian Feb 03 '18 at 12:52
  • 1
    The funny thing is in the question it's stated *"A MaskTextBox won't work as I need multiline support."* but some users are still voting for the *"MaskTextBox"* answer. In fact they even don't read the question! – Reza Aghaei Feb 03 '18 at 13:29
  • 1
    note that proportional fonts are going to be a curse for this - in any font that is not monospaced, "iii" takes up a LOT less horizontal room than "WWW" - and neither is the same as "___". I notice that at least one of the solutions offered uses a fixed-width font for the blanks; whatever you choose, don't forget this aspect. – whybird Feb 04 '18 at 23:37
  • @RezaAghaei I did post my answer before the OP stated that they needed multi line support. – Gravitate Feb 05 '18 at 12:58
  • 1
    @Gravitate Your answer is really good and I saw it before the OP added the multi-line requirement. My comment above, is just about those users who don't read the question, it's not about the answer :) – Reza Aghaei Feb 05 '18 at 16:07
  • 1
    I've added multiline "awareness" to the code I previously posted. I know it's late, but maybe it could be of some use. – Jimi Feb 06 '18 at 13:46

10 Answers10

16

One option is to use a Masked Textbox.

In your example, you would set the mask to:

"The quick brown fox jLLLed over the l\azy hound"

Which would appear as:

"The quick brown fox j___ed over the lazy hound"

And only allow 3 characters (a-z & A-Z) to be entered into the gap. And the mask could be easily changed via code.

EDIT: For convenience...

Here is a list and description of masking characters

(taken from http://www.c-sharpcorner.com/uploadfile/mahesh/maskedtextbox-in-C-Sharp/).

0 - Digit, required. Value between 0 and 9.
9 - Digit or space, optional.
# - Digit or space, optional. If this position is blank in the mask, it will be rendered as a space in the Text property.
L - Letter, required. Restricts input to the ASCII letters a-z and A-Z.
? - Letter, optional. Restricts input to the ASCII letters a-z and A-Z.
& - Character, required.
C - Character, optional. Any non-control character.
A - Alphanumeric, required.
a - Alphanumeric, optional.
.  - Decimal placeholder.
, - Thousands placeholder.
: - Time separator.
/ - Date separator.
$ - Currency symbol.
< - Shift down. Converts all characters that follow to lowercase.
> - Shift up. Converts all characters that follow to uppercase.
| - Disable a previous shift up or shift down.
\ - Escape. Escapes a mask character, turning it into a literal. "\\" is the escape sequence for a backslash.

All other characters - Literals. All non-mask elements will appear as themselves within MaskedTextBox. Literals always occupy a static position in the mask at run time, and cannot be moved or deleted by the user.

Gravitate
  • 2,885
  • 2
  • 21
  • 37
  • 2
    This would be ideal, but I just realised I need multiline support, as some of the input strings are longer than one line. – Galwegian Jan 23 '18 at 15:35
  • All I can suggest is the accepted answer in this post: https://stackoverflow.com/questions/20140622/multiline-mask-for-maskedtextbox Unfortunately, it's a bit of extra work to create your own user control and add the functionality you need, but I don't know of any other way. Sorry – Gravitate Jan 23 '18 at 16:55
6

To satisfy this requirement, IMO it's better to use those features of Windows Forms which allow interoperability with HTML or WPF and Host a WebBrowser control or a WPF ElementHost to show the content to users. Before reading this answer, please consider:

  • Users should not be able to clear the ____ fields. If they can clear them, once they moved to another blank, they will lose the ability to find the cleared field.
  • It's better to allow users to use Tab key to move between ____ fields.
  • As it's mentioned in the question: A MaskTextBox won't work as I need multiline support.
  • As it's mentioned in the question: There'll be 300+ strings so mixing a lot of Windows Forms control is not a good idea.

Using Html as View of a C# model and show it in WebBrowser control

Here I will share a simple answer based on showing HTML in WebBrowser control. As an option you can use a WebBrowser control and create suitable html to show in WebBrowser control using a mode class.

The main idea is creating an html output based on the quiz model (including the original text and ragnes of blanks) and rendering the model using html and showing it in a WebBrowser control.

For example using following model:

quiz = new Quiz();
quiz.Text = @"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
quiz.Ranges.Add(new SelectionRange(6, 5));
quiz.Ranges.Add(new SelectionRange(30, 7));
quiz.Ranges.Add(new SelectionRange(61, 2));
quiz.Ranges.Add(new SelectionRange(82, 6));

It will render this output:

fill in the blank - initial

Then after the user entered values, it will show this way:

fill in the blank - having answers

And at last, when you click on Show Result button, it will show the correct answers in green color, and wrong answers in red color:

fill in the blank - showing results

Code

You can download full working source code for example here:

The implementation is quiet simple:

public class Quiz
{
    public Quiz() { Ranges = new List<SelectionRange>(); }
    public string Text { get; set; }
    public List<SelectionRange> Ranges { get; private set; }
    public string Render()
    {
        /* rendering logic*/
    }
}

Here is the complete code of the Quiz class:

public class Quiz
{
    public Quiz() { Ranges = new List<SelectionRange>(); }
    public string Text { get; set; }
    public List<SelectionRange> Ranges { get; private set; }
    public string Render()
    {
        var content = new StringBuilder(Text);
        for (int i = Ranges.Count - 1; i >= 0; i--)
        {
            content.Remove(Ranges[i].Start, Ranges[i].Length);
            var length = Ranges[i].Length;
            var replacement = $@"<input id=""q{i}"" 
                type=""text"" class=""editable""
                maxlength=""{length}"" 
                style=""width: {length*1.162}ch;"" />";
            content.Insert(Ranges[i].Start, replacement);
        }
        var result = string.Format(Properties.Resources.Template, content);
        return result;
    }
}

public class SelectionRange
{
    public SelectionRange(int start, int length)
    {
        Start = start;
        Length = length;
    }
    public int Start { get; set; }
    public int Length { get; set; }
}

And here is the content of the html template:

<html>
    <head>
    <meta http-equiv="X-UA-Compatible" content="IE=11" />
    <script>
        function setCorrect(id){{document.getElementById(id).className = 'editable correct';}}
        function setWrong(id){{document.getElementById(id).className = 'editable wrong';}}
    </script>
    <style>
        div {{
            line-height: 1.5;
            font-family: calibri;
        }}
        .editable {{
            border-width: 0px;
            border-bottom: 1px solid #cccccc;
            font-family: monospace;
            display: inline-block;
            outline: 0;
            color: #0000ff;
            font-size: 105%;
        }}
        .editable.correct
        {{    
            color: #00ff00;
            border-bottom: 1px solid #00ff00;
        }}
        .editable.wrong
        {{    
            color: #ff0000;
            border-bottom: 1px solid #ff0000;
        }}
        .editable::-ms-clear {{
            width: 0;
            height: 0;
        }}
    </style>
    </head>
    <body>
    <div>
    {0}
    </div>
    </body>
</html>
Reza Aghaei
  • 120,393
  • 18
  • 203
  • 398
  • You can download the full source code for the example here: https://github.com/r-aghaei/FillInTheBlankQuizSample – Reza Aghaei Feb 04 '18 at 07:51
  • 2
    I don't argue that this works, but OP asked for Winforms solution. Otherwise the question would be "How to wrap an elegant web solution to a Winforms control. Also this calls for answers like "I know WPF, here is my solution and [here is how you can wrap it in Winforms](https://learn.microsoft.com/en-us/dotnet/framework/wpf/advanced/walkthrough-hosting-a-wpf-composite-control-in-windows-forms). – Stelios Adamantidis Feb 04 '18 at 15:06
  • @SteliosAdamantidis Using Html is not a web solution. Windows Forms contains a WebBrowser control to show html contents and it allows interoperability between .Net code and browser content. WPF is also an option. You can create the XAML content at run-time and show in a WPF user control. IMO for this requirement, it's better to use HTML or WPF rather than mixing a lot of Windows Forms controls, consider the OP said there will be 300+ strings. We also can develop a single control which works like a multi-line mask edit, but it takes more time to create and it would be much more expensive. – Reza Aghaei Feb 04 '18 at 19:11
5

enter image description here

Work out which character was clicked on, if it was an underscore then size up the underscores left and right and show a textbox on top of the underscores.

You can tweak this code, the label is actually a Read-Only textbox to get acces to the GetCharIndexFromPosition and GetPositionFromCharIndex methods.

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        private System.Windows.Forms.TextBox txtGap;
        private System.Windows.Forms.Label label2;
        private System.Windows.Forms.Label lblClickedOn;
        private System.Windows.Forms.TextBox txtTarget;

        private void txtTarget_MouseDown(object sender, MouseEventArgs e)
        {
            int index = txtTarget.GetCharIndexFromPosition(e.Location);
            //Debugging help
            Point pt = txtTarget.GetPositionFromCharIndex(index);
            lblClickedOn.Text = index.ToString();

            txtGap.Visible = false;

            if (txtTarget.Text[index] == (char)'_')
            {
                //Work out the left co-ordinate for the textbox by checking the number of underscores prior
                int priorLetterToUnderscore = 0;
                for (int i = index - 1; i > -1; i--)
                {
                    if (txtTarget.Text[i] != (char)'_')
                    {
                        priorLetterToUnderscore = i + 1;
                        break;
                    }
                }

                int afterLetterToUnderscore = 0;
                for (int i = index + 1; i <= txtTarget.Text.Length; i++)
                {
                    if (txtTarget.Text[i] != (char)'_')
                    {
                        afterLetterToUnderscore = i;
                        break;
                    }
                }


                //Measure the characters width earlier than the priorLetterToUnderscore
                pt = txtTarget.GetPositionFromCharIndex(priorLetterToUnderscore);
                int left = pt.X + txtTarget.Left;

                pt = txtTarget.GetPositionFromCharIndex(afterLetterToUnderscore);
                int width = pt.X + txtTarget.Left - left;

                //Check the row/line we are on
                SizeF textSize = this.txtTarget.CreateGraphics().MeasureString("A", this.txtTarget.Font, this.txtTarget.Width);
                int line = pt.Y / (int)textSize.Height;

                txtGap.Location = new Point(left, txtTarget.Top + (line * (int)textSize.Height));
                txtGap.Width = width;
                txtGap.Text = string.Empty;
                txtGap.Visible = true;

             }
        }

        private void Form1_Click(object sender, EventArgs e)
        {
            txtGap.Visible = false;
        }

        public Form1()
        {
            this.txtGap = new System.Windows.Forms.TextBox();
            this.label2 = new System.Windows.Forms.Label();
            this.lblClickedOn = new System.Windows.Forms.Label();
            this.txtTarget = new System.Windows.Forms.TextBox();
            this.SuspendLayout();
            // 
            // txtGap
            // 
            this.txtGap.Font = new System.Drawing.Font("Microsoft Sans Serif", 6.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
            this.txtGap.Location = new System.Drawing.Point(206, 43);
            this.txtGap.Name = "txtGap";
            this.txtGap.Size = new System.Drawing.Size(25, 20);
            this.txtGap.TabIndex = 1;
            this.txtGap.Text = "ump";
            this.txtGap.Visible = false;
            // 
            // label2
            // 
            this.label2.AutoSize = true;
            this.label2.Location = new System.Drawing.Point(22, 52);
            this.label2.Name = "label2";
            this.label2.Size = new System.Drawing.Size(84, 13);
            this.label2.TabIndex = 2;
            this.label2.Text = "Char clicked on:";
            // 
            // lblClickedOn
            // 
            this.lblClickedOn.AutoSize = true;
            this.lblClickedOn.Location = new System.Drawing.Point(113, 52);
            this.lblClickedOn.Name = "lblClickedOn";
            this.lblClickedOn.Size = new System.Drawing.Size(13, 13);
            this.lblClickedOn.TabIndex = 3;
            this.lblClickedOn.Text = "_";
            // 
            // txtTarget
            // 
            this.txtTarget.BackColor = System.Drawing.SystemColors.Menu;
            this.txtTarget.BorderStyle = System.Windows.Forms.BorderStyle.None;
            this.txtTarget.Font = new System.Drawing.Font("Microsoft Sans Serif", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
            this.txtTarget.Location = new System.Drawing.Point(22, 21);
            this.txtTarget.Name = "txtTarget";
            this.txtTarget.ReadOnly = true;
            this.txtTarget.Size = new System.Drawing.Size(317, 16);
            this.txtTarget.TabIndex = 4;
            this.txtTarget.Text = "The quick brown fox j___ed over the l__y hound";
            this.txtTarget.MouseDown += new System.Windows.Forms.MouseEventHandler(this.txtTarget_MouseDown);
            // 
            // Form1
            // 
            this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
            this.ClientSize = new System.Drawing.Size(394, 95);
            this.Controls.Add(this.txtGap);
            this.Controls.Add(this.txtTarget);
            this.Controls.Add(this.lblClickedOn);
            this.Controls.Add(this.label2);
            this.Name = "Form1";
            this.Text = "Form1";
            this.Click += new System.EventHandler(this.Form1_Click);
            this.ResumeLayout(false);
            this.PerformLayout();
        }
    }        
}

To disable the Textbox (fake label) from being selected: https://stackoverflow.com/a/42391380/495455

Edit:

I made it work for multiline textboxes:

enter image description here

Jeremy Thompson
  • 61,933
  • 36
  • 195
  • 321
  • Relying on `_` is not good idea, because after editing the blank, and moved to the next blank, they will miss the opportunity to change the previous one which they just edited. Also not using a mono-space font for those controls, will lead to the effect which you can see in the first screenshot, there are not enough space to show all characters which user is typing, for example MMM width is different than lll. – Reza Aghaei Feb 05 '18 at 12:14
  • 1
    I am with you @Reza, I agree. Perhaps spaces either side of the underscores. I was just trying to code it as OP asked and stated he should feel free to tweak it. Not looking for any disgreements on here mate. Just trying to help. – Jeremy Thompson Feb 06 '18 at 08:39
4

This may be overkill depending on how complex you want this to be, but a winforms web browser control (which is essentially MSIE running inside your Winforms app) can work as an editor where you control which parts are editable.

Load your content with the editable parts tagged as such, e.g.:

<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=10" />
<style>
  span.spEditable { background-color: #f0f0f0; }
</style>
</head>
<body>
<div id="someText">The quick brown fox j<span contenteditable="true" class="spEditable">___</span>ed over the l<span contenteditable="true" class="spEditable">__</span>y hound</div>
</body>
</html>

Another option, a bit more work to code but more lightweight in terms of memory/resources, would be to use a FlowLayoutPanel, add normal panels to the FlowLayoutPanel, and then put either labels or textboxes on those panels depending on if a panel represent a fixed or editable portion, and resize them to match the length of the content. You can use MeasureString to find out the width of the content in each label/textbox for resizing purposes.

KristoferA
  • 12,287
  • 1
  • 40
  • 62
  • it's a winforms question though, not asp.net. – Nyerguds Feb 01 '18 at 13:17
  • In a `Windows Forms` project you can use a `WebBrowser` control to show `Html`. You also can have [interactions between `WebBrowser` control and your `Form`](https://stackoverflow.com/q/34834774/3110834), and for example figure out if the specific span contains correct letters. – Reza Aghaei Feb 01 '18 at 13:51
  • That surely is an overkill, but, my, yet another case example how to replace torture of doing custom things in WinForms by a web-view. – quetzalcoatl Feb 02 '18 at 12:51
3

Another quiz solution, using a class derived from TextBox a the editor for the missing letters.

What this code does:
1) Takes the Text of a Label control, a list of strings (words) and substrings of those words which are used as a mask to hide some of the letters in the words
2) Creates a mask of the substrings using two Unicode space chars (U+2007 and U+2002) of different sizes, to match the size of the letters to substitute
3) Sizes up a border-less TextBox (the Editor, a class object that inherits from Textbox) using the calculated Width and Height (in pixels) of the substring. Sets the TextBox.MaxLength property to the length of the substring.
4) Calculates the position of the substrings inside a Multiline Label Text, checks for duplicate patterns, and overlays the Texbox objects (Editor class)

This method supports:
Proportional fonts. Only Unicode Fonts are supported.
The Labels' text can occupy multiple lines.

I've use a fixed-size font (Lucida Console) because of the mask char.
To deal with proportional fonts, two different mask chars are used, depending on the characters width
(i.e. different mask chars of different width to match the substituted characters width).

A visual representation of the results:
The TAB key is used to pass from a TextBox control to the next/previous.
The ENTER key is used to accept the edit. Then the code checks if it's a match.
The ESC key resets the text and shows the initial mask.

enter image description here

A list of words is initialized specifying a complete word and a number of contiguous characters to substitute with a mask: => jumped : umpe
and the associated Label control.
When a Quiz class is initialized, it automatically subtitutes all words in the specified Label text with a TextBox mask.

public class QuizWord
{
    public string Word { get; set; }
    public string WordMask { get; set; }
}

List<Quiz> QuizList = new List<Quiz>();

QuizList.Add(new Quiz(lblSampleText1,
             new List<QuizWord>  
           { new QuizWord { Word = "jumped", WordMask = "umpe" }, 
             new QuizWord { Word = "lazy", WordMask = "az" } }));
QuizList.Add(new Quiz(lblSampleText2,
             new List<QuizWord>  
           { new QuizWord { Word = "dolor", WordMask = "olo" }, 
             new QuizWord { Word = "elit", WordMask = "li" } }));
QuizList.Add(new Quiz(lblSampleText3,
             new List<QuizWord>  
           { new QuizWord { Word = "Brown", WordMask = "row" }, 
             new QuizWord { Word = "Foxes", WordMask = "oxe" }, 
             new QuizWord { Word = "latinorum", WordMask = "atinoru" },
             new QuizWord { Word = "Support", WordMask = "uppor" } }));

This is the Quiz class:
It's job is to collect all the Editors (TextBoxes) that are used for each Label and calculate their Location, given the position of the string they have to substitute in each Label text.

public class Quiz : IDisposable
{
    private bool _disposed = false;
    private List<QuizWord> _Words = new List<QuizWord>();
    private List<Editor> _Editors = new List<Editor>();
    private MultilineSupport _Multiline;
    private Control _Container = null;

    public Quiz() : this(null, null) { }
    public Quiz(Label RefControl, List<QuizWord> Words)
    {
        this._Container = RefControl.Parent;

        this.Label = null;
        if (RefControl != null)
        {
            this.Label = RefControl;
            this.Matches = new List<QuizWord>();
            if (Words != null)
            {
                this._Multiline = new MultilineSupport(RefControl);
                this.Matches = Words;
            }
        }
    }

    public Label Label { get; set; }
    public List<QuizWord> Matches
    {
        get { return this._Words; }
        set { this._Words = value; Editors_Setup(); }
    }

    private void Editors_Setup()
    {
        if ((this._Words == null) || (this._Words.Count < 1)) return;
        int i = 1;
        foreach (QuizWord _word in _Words)
        {
            List<Point> _Positions = GetEditorsPosition(this.Label.Text, _word);
            foreach (Point _P in _Positions)
            {
                Editor _editor = new Editor(this.Label, _word.WordMask);
                _editor.Location = _P;
                _editor.Name = this.Label.Name + "Editor" + i.ToString(); ++i;
                _Editors.Add(_editor);
                this._Container.Controls.Add(_editor);
                this._Container.Controls[_editor.Name].BringToFront();
            }
        }
    }

    private List<Point> GetEditorsPosition(string _labeltext, QuizWord _word)
    {
        return  Regex.Matches(_labeltext, _word.WordMask) 
                     .Cast<Match>()
                     .Select(t => t.Index).ToList()
                     .Select(idx => this._Multiline.GetPositionFromCharIndex(idx))
                     .ToList();
    }

    private class MultilineSupport
    {
        Label RefLabel;
        float _FontSpacingCoef = 1.8F;
        private TextFormatFlags _flags = TextFormatFlags.SingleLine | TextFormatFlags.Left |
                                         TextFormatFlags.NoPadding | TextFormatFlags.TextBoxControl;

        public MultilineSupport(Label label)
        {
            this.Lines = new List<string>();
            this.LinesFirstCharIndex = new List<int>();
            this.NumberOfLines = 0;
            Initialize(label);
        }

        public int NumberOfLines { get; set; }
        public List<string> Lines { get; set; }
        public List<int> LinesFirstCharIndex { get; set; }

        public int GetFirstCharIndexFromLine(int line)
        {
            if (LinesFirstCharIndex.Count == 0) return -1;
            return LinesFirstCharIndex.Count - 1 >= line ? LinesFirstCharIndex[line] : -1;
        }

        public int GetLineFromCharIndex(int index)
        {
            if (LinesFirstCharIndex.Count == 0) return -1;
            return LinesFirstCharIndex.FindLastIndex(idx => idx <= Index);;
        }

        public Point GetPositionFromCharIndex(int Index)
        {
            return CalcPosition(GetLineFromCharIndex(Index), Index);
        }

        private void Initialize(Label label)
        {
            this.RefLabel = label;
            if (label.Text.Trim().Length == 0)
                return;

            List<string> _wordslist = new List<string>();
            string _substring = string.Empty;
            this.LinesFirstCharIndex.Add(0);
            this.NumberOfLines = 1;
            int _currentlistindex = 0;
            int _start = 0;

            _wordslist.AddRange(label.Text.Split(new char[] { (char)32 }, StringSplitOptions.None));
            foreach (string _word in _wordslist)
            {
                ++_currentlistindex;
                int _wordindex = label.Text.IndexOf(_word, _start);
                int _sublength = MeasureString((_substring + _word + (_currentlistindex < _wordslist.Count ? ((char)32).ToString() : string.Empty)));
                if (_sublength > label.Width)
                {
                    this.Lines.Add(_substring);
                    this.LinesFirstCharIndex.Add(_wordindex);
                    this.NumberOfLines += 1;
                    _substring = string.Empty;
                }
                _start += _word.Length + 1;
                _substring += _word + (char)32;
            }
            this.Lines.Add(_substring.TrimEnd());
        }

        private Point CalcPosition(int Line, int Index)
        {
            int _font_padding = (int)((RefLabel.Font.Size - (int)(RefLabel.Font.Size % 12)) * _FontSpacingCoef);
            int _verticalpos = Line * this.RefLabel.Font.Height + this.RefLabel.Top;
            int _horizontalpos = MeasureString(this.Lines[Line].Substring(0, Index - GetFirstCharIndexFromLine(Line)));
            return new Point(_horizontalpos + _font_padding, _verticalpos);
        }

        private int MeasureString(string _string)
        {
            return TextRenderer.MeasureText(RefLabel.CreateGraphics(), _string,
                                            this.RefLabel.Font, this.RefLabel.Size, _flags).Width;
        }
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected void Dispose(bool IsSafeDisposing)
    {
        if (IsSafeDisposing && (!this._disposed))
        {
            foreach (Editor _editor in _Editors)
                if (_editor != null) _editor.Dispose();
            this._disposed = true;
        }
    }
}

This is the Editor class (inherits from TextBox):
It builds and calculates the length of the mask chars and autosizes itself using this value.
Has basic editing capabilities.

public class Editor : TextBox
{
    private string SubstChar = string.Empty;
    private string SubstCharLarge = ((char)0x2007).ToString();
    private string SubstCharSmall = ((char)0x2002).ToString();
    private Font NormalFont = null;
    private Font UnderlineFont = null;
    private string WordMask = string.Empty;
    private TextFormatFlags _flags = TextFormatFlags.NoPadding | TextFormatFlags.Left |
                                     TextFormatFlags.Bottom | TextFormatFlags.WordBreak |
                                     TextFormatFlags.TextBoxControl;

    public Editor(Label RefLabel, string WordToMatch)
    {
        this.BorderStyle = BorderStyle.None;
        this.TextAlign = HorizontalAlignment.Left;
        this.Margin = new Padding(0);

        this.MatchWord = WordToMatch;
        this.MaxLength = WordToMatch.Length;
        this._Label = RefLabel;
        this.NormalFont = RefLabel.Font;
        this.UnderlineFont = new Font(RefLabel.Font, (RefLabel.Font.Style | FontStyle.Underline));
        this.Font = this.UnderlineFont;
        this.Size = GetTextSize(WordToMatch);
        this.WordMask = CreateMask(this.Size.Width);
        this.Text = this.WordMask;
        this.BackColor = RefLabel.BackColor;
        this.ForeColor = RefLabel.ForeColor;

        this.KeyDown += this.KeyDownHandler;
        this.Enter += (sender, e) => { this.Font = this.UnderlineFont; this.SelectionStart = 0;  this.SelectionLength = 0; };
        this.Leave += (sender, e) => { CheckWordMatch(); };
    }

    public string MatchWord { get; set; }
    private Label _Label { get; set; }

    public void KeyDownHandler(object sender, KeyEventArgs e)
    {
        int _start = this.SelectionStart;
        switch (e.KeyCode)
        {
        case Keys.Back:
            if (this.SelectionStart > 0)
            {
                this.AppendText(SubstChar);
                this.SelectionStart = 0;
                this.ScrollToCaret();
            }
            this.SelectionStart = _start;
            break;
        case Keys.Delete:
            if (this.SelectionStart < this.Text.Length)
            {
                this.AppendText(SubstChar);
                this.SelectionStart = 0;
                this.ScrollToCaret();
            }
            this.SelectionStart = _start;
            break;
        case Keys.Enter:
            e.SuppressKeyPress = true;
            CheckWordMatch();
            break;
        case Keys.Escape:
            e.SuppressKeyPress = true;
            this.Text = this.WordMask;
            this.ForeColor = this._Label.ForeColor;
            break;
        default:
            if ((e.KeyCode >= (Keys)32 & e.KeyCode <= (Keys)127) && (e.KeyCode < (Keys)36 | e.KeyCode > (Keys)39))
            {
                int _removeat = this.Text.LastIndexOf(SubstChar);
                if (_removeat > -1) this.Text = this.Text.Remove(_removeat, 1);
                this.SelectionStart = _start;
            }
            break;
        }
    }
    private void CheckWordMatch()
    {
        if (this.Text != this.WordMask) {
            this.Font = this.Text == this.MatchWord ? this.NormalFont : this.UnderlineFont;
            this.ForeColor = this.Text == this.MatchWord ? Color.Green : Color.Red;
        } else {
            this.ForeColor = this._Label.ForeColor;
        }
    }

    private Size GetTextSize(string _mask)
    {
        return TextRenderer.MeasureText(this._Label.CreateGraphics(), _mask, this._Label.Font, this._Label.Size, _flags);
    }

    private string CreateMask(int _EditorWidth)
    {
        string _TestMask = new StringBuilder().Insert(0, SubstCharLarge, this.MatchWord.Length).ToString();
        SubstChar = (GetTextSize(_TestMask).Width <= _EditorWidth) ? SubstCharLarge : SubstCharSmall;
        return SubstChar == SubstCharLarge 
                          ? _TestMask  
                          : new StringBuilder().Insert(0, SubstChar, this.MatchWord.Length).ToString();
    }
}
Jimi
  • 29,621
  • 8
  • 43
  • 61
  • *OP: There'll be 300+ strings* → Having 300+ `Label` and 300+ `TextBox` controls is not good idea. Also having each string as a single line label is not good idea, most users need multi line and word wrapping based on size of the window. Having the whole strings in mono-space font is not friendly. Also handling keystrokes is a bit error prone, for example you forget to handle cases that user paste something or maybe the word is a composite word containing `-` or `'` or something. – Reza Aghaei Feb 05 '18 at 12:00
  • In fact, if the users can be satisfied with single line strings, why not using `MaskedTextBox`? – Reza Aghaei Feb 05 '18 at 12:16
  • 1
    @Reza Aghaei _I'll be feeding a number of strings into labels_: the OP said he has to feed 300+ strings into labels, not that there will be 300+ controls on a Form. - I know (and I said) that the editor is not perfect (btw, besides what you pointed out, I saw just now something else worth a tweak, I'll make an edit), but it wasn't really the main request. - If the length of a string can cause word-wrapping, that can be adjusted. I mainly focused on the direct question _How do I reposition the textbox accurately for each string?_ – Jimi Feb 05 '18 at 13:00
  • @Reza Aghaei _why not using MaskedTextBox?_. Because the OP said he needs multiline support (for what, he didn't say). He discarded it. Maybe he will clarify his reasons, giving more specifications. Maybe not. – Jimi Feb 05 '18 at 13:08
  • I mean, your solution also doesn't support multi-line and word-wrapping. – Reza Aghaei Feb 05 '18 at 14:46
  • @Reza Aghaei Agreed, but he didn't ask. An OP edit states - even if it's not that clear to me - that he can't/won't use a MaskedEdit because he needs multiline support. If the labels' text can be wrapped, that doesn't touch the MaskedEdit functionality (the editor just needs to be overlaid on a substring). If he's interested in my proposal and he needs multiline text, I will add that functionality (multiline positioning). I've already posted [something like that](https://stackoverflow.com/questions/48222621/how-to-highlight-the-wrapped-text-in-a-control-using-the-graphics/48257170#48257170) – Jimi Feb 05 '18 at 15:10
2

Consider using a combination of a DataGridView and a Masked Cell column.

On Edit Control Showing, you would change the mask of that particular row.

Here's some code usage example that includes the grid and the unique masking for each row.

Public Class Form1
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load

        Dim mec As New MaskedEditColumn
        mec.Mask = ""
        mec.DataPropertyName = "Data"
        Me.DataGridView1.Columns.Add(mec)


        Dim tbl As New Data.DataTable
        tbl.Columns.Add("Data")
        tbl.Columns.Add("Mask")
        tbl.Rows.Add(New Object() {"The quick brown fox j   ed over the lazy hound", "The quick brown fox jaaaed over the l\azy hound"})
        tbl.Rows.Add(New Object() {"    quick brown fox j   ed over the lazy hound", "aaa quick brown fox jaaaed over the l\azy hound"})
        tbl.Rows.Add(New Object() {"The       brown fox j   ed over the lazy hound", "The aaaaa brown fox jaaaed over the l\azy hound"})
        Me.DataGridView1.AutoGenerateColumns = False
        Me.DataGridView1.DataSource = tbl
    End Sub

    Private Sub DataGridView1_EditingControlShowing(sender As Object, e As DataGridViewEditingControlShowingEventArgs) Handles DataGridView1.EditingControlShowing

        If e.Control.GetType().Equals(GetType(MaskedEditingControl)) Then

            Dim mec As MaskedEditingControl = e.Control
            Dim row As DataGridViewRow = Me.DataGridView1.CurrentRow
            mec.Mask = row.DataBoundItem("Mask")

        End If

    End Sub
End Class

And the grid column, sourced from here: http://www.vb-tips.com/MaskedEditColumn.aspx

Public Class MaskedEditColumn
    Inherits DataGridViewColumn
    Public Sub New()
        MyBase.New(New MaskedEditCell())
    End Sub
    Public Overrides Property CellTemplate() As DataGridViewCell
        Get
            Return MyBase.CellTemplate
        End Get
        Set(ByVal value As DataGridViewCell)

            ' Ensure that the cell used for the template is a CalendarCell.
            If Not (value Is Nothing) AndAlso
                Not value.GetType().IsAssignableFrom(GetType(MaskedEditCell)) _
                Then
                Throw New InvalidCastException("Must be a MaskedEditCell")
            End If
            MyBase.CellTemplate = value
        End Set
    End Property
    Private m_strMask As String
    Public Property Mask() As String
        Get
            Return m_strMask
        End Get
        Set(ByVal value As String)
            m_strMask = value
        End Set
    End Property
    Private m_tyValidatingType As Type
    Public Property ValidatingType() As Type
        Get
            Return m_tyValidatingType
        End Get
        Set(ByVal value As Type)
            m_tyValidatingType = value
        End Set
    End Property
    Private m_cPromptChar As Char = "_"c
    Public Property PromptChar() As Char
        Get
            Return m_cPromptChar
        End Get
        Set(ByVal value As Char)
            m_cPromptChar = value
        End Set
    End Property
    Private ReadOnly Property MaskedEditCellTemplate() As MaskedEditCell
        Get
            Return TryCast(Me.CellTemplate, MaskedEditCell)
        End Get
    End Property
End Class
Public Class MaskedEditCell
    Inherits DataGridViewTextBoxCell
    Public Overrides Sub InitializeEditingControl(ByVal rowIndex As Integer,
        ByVal initialFormattedValue As Object,
        ByVal dataGridViewCellStyle As DataGridViewCellStyle)
        ' Set the value of the editing control to the current cell value.
        MyBase.InitializeEditingControl(rowIndex, initialFormattedValue,
            dataGridViewCellStyle)
        Dim mecol As MaskedEditColumn = DirectCast(OwningColumn, MaskedEditColumn)
        Dim ctl As MaskedEditingControl =
            CType(DataGridView.EditingControl, MaskedEditingControl)
        Try
            ctl.Text = Me.Value.ToString
        Catch
            ctl.Text = ""
        End Try
        ctl.Mask = mecol.Mask
        ctl.PromptChar = mecol.PromptChar
        ctl.ValidatingType = mecol.ValidatingType
    End Sub
    Public Overrides ReadOnly Property EditType() As Type
        Get
            ' Return the type of the editing contol that CalendarCell uses.
            Return GetType(MaskedEditingControl)
        End Get
    End Property
    Public Overrides ReadOnly Property ValueType() As Type
        Get
            ' Return the type of the value that CalendarCell contains.
            Return GetType(String)
        End Get
    End Property
    Public Overrides ReadOnly Property DefaultNewRowValue() As Object
        Get
            ' Use the current date and time as the default value.
            Return ""
        End Get
    End Property
    Protected Overrides Sub Paint(ByVal graphics As System.Drawing.Graphics, ByVal clipBounds As System.Drawing.Rectangle, ByVal cellBounds As System.Drawing.Rectangle, ByVal rowIndex As Integer, ByVal cellState As System.Windows.Forms.DataGridViewElementStates, ByVal value As Object, ByVal formattedValue As Object, ByVal errorText As String, ByVal cellStyle As System.Windows.Forms.DataGridViewCellStyle, ByVal advancedBorderStyle As System.Windows.Forms.DataGridViewAdvancedBorderStyle, ByVal paintParts As System.Windows.Forms.DataGridViewPaintParts)
        MyBase.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts)
    End Sub
End Class
Class MaskedEditingControl
    Inherits MaskedTextBox
    Implements IDataGridViewEditingControl
    Private dataGridViewControl As DataGridView
    Private valueIsChanged As Boolean = False
    Private rowIndexNum As Integer
    Public Property EditingControlFormattedValue() As Object _
        Implements IDataGridViewEditingControl.EditingControlFormattedValue
        Get
            Return Me.Text
        End Get
        Set(ByVal value As Object)
            Me.Text = value.ToString
        End Set
    End Property
    Public Function EditingControlWantsInputKey(ByVal key As Keys,
           ByVal dataGridViewWantsInputKey As Boolean) As Boolean _
           Implements IDataGridViewEditingControl.EditingControlWantsInputKey
        Return True
    End Function
    Public Function GetEditingControlFormattedValue(ByVal context _
        As DataGridViewDataErrorContexts) As Object _
        Implements IDataGridViewEditingControl.GetEditingControlFormattedValue
        Return Me.Text
    End Function
    Public Sub ApplyCellStyleToEditingControl(ByVal dataGridViewCellStyle As _
        DataGridViewCellStyle) _
        Implements IDataGridViewEditingControl.ApplyCellStyleToEditingControl

        Me.Font = dataGridViewCellStyle.Font
        Me.ForeColor = dataGridViewCellStyle.ForeColor
        Me.BackColor = dataGridViewCellStyle.BackColor
    End Sub
    Public Property EditingControlRowIndex() As Integer _
        Implements IDataGridViewEditingControl.EditingControlRowIndex
        Get
            Return rowIndexNum
        End Get
        Set(ByVal value As Integer)
            rowIndexNum = value
        End Set
    End Property
    Public Sub PrepareEditingControlForEdit(ByVal selectAll As Boolean) _
        Implements IDataGridViewEditingControl.PrepareEditingControlForEdit
        ' No preparation needs to be done.
    End Sub
    Public ReadOnly Property RepositionEditingControlOnValueChange() _
        As Boolean Implements _
        IDataGridViewEditingControl.RepositionEditingControlOnValueChange
        Get
            Return False
        End Get
    End Property
    Public Property EditingControlDataGridView() As DataGridView _
        Implements IDataGridViewEditingControl.EditingControlDataGridView
        Get
            Return dataGridViewControl
        End Get
        Set(ByVal value As DataGridView)
            dataGridViewControl = value
        End Set
    End Property

    Public Property EditingControlValueChanged() As Boolean _
        Implements IDataGridViewEditingControl.EditingControlValueChanged
        Get
            Return valueIsChanged
        End Get
        Set(ByVal value As Boolean)
            valueIsChanged = value
        End Set
    End Property
    Public ReadOnly Property EditingControlCursor() As Cursor _
        Implements IDataGridViewEditingControl.EditingPanelCursor
        Get
            Return MyBase.Cursor
        End Get
    End Property
    Protected Overrides Sub OnTextChanged(ByVal e As System.EventArgs)
        ' Notify the DataGridView that the contents of the cell have changed.
        valueIsChanged = True
        Me.EditingControlDataGridView.NotifyCurrentCellDirty(True)
        MyBase.OnTextChanged(e)
    End Sub
End Class
Ctznkane525
  • 7,297
  • 3
  • 16
  • 40
  • I just fixed it, didn't even read this answer but upvoted simply based on the effort. I reckon someone didn't see the VB.net tag or something. – Jeremy Thompson Feb 02 '18 at 21:14
2

This is how I would approach it. Split with regular expression the string and create separate labels for each of the sub-strings. Put all the labels in a FlowLayoutPanel. When a label is clicked, remove it and on the same position add the editing TextBox. When the focus is lost (or enter is pressed) remove the TextBox and put the Label back; set the text of the label to the text of the TextBox.

First create custom UserControl like the following

public partial class WordEditControl : UserControl
{
    private readonly Regex underscoreRegex = new Regex("(__*)");
    private List<EditableLabel> labels = new List<EditableLabel>();

    public WordEditControl()
    {
        InitializeComponent();
    }

    public void SetQuizText(string text)
    {
        contentPanel.Controls.Clear();
        foreach (string item in underscoreRegex.Split(text))
        {
            var label = new Label
            {
                FlatStyle = FlatStyle.System,
                Padding = new Padding(),
                Margin = new Padding(0, 3, 0, 0),
                TabIndex = 0,
                Text = item,
                BackColor = Color.White,
                TextAlign = ContentAlignment.TopCenter
            };
            if (item.Contains("_"))
            {
                label.ForeColor = Color.Red;
                var edit = new TextBox
                {
                    Margin = new Padding()
                };
                labels.Add(new EditableLabel(label, edit));

            }
            contentPanel.Controls.Add(label);
            using (Graphics g = label.CreateGraphics())
            {
                SizeF textSize = g.MeasureString(item, label.Font);
                label.Size = new Size((int)textSize.Width - 4, (int)textSize.Height);
            }
        }
    }

    // Copied it from the .Designer file for the sake of completeness
    private void InitializeComponent()
    {
        this.contentPanel = new System.Windows.Forms.FlowLayoutPanel();
        this.SuspendLayout();
        // 
        // contentPanel
        // 
        this.contentPanel.Dock = System.Windows.Forms.DockStyle.Fill;
        this.contentPanel.Location = new System.Drawing.Point(0, 0);
        this.contentPanel.Name = "contentPanel";
        this.contentPanel.Size = new System.Drawing.Size(150, 150);
        this.contentPanel.TabIndex = 0;
        // 
        // WordEditControl
        // 
        this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
        this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
        this.Controls.Add(this.contentPanel);
        this.Name = "WordEditControl";
        this.ResumeLayout(false);

    }

    private System.Windows.Forms.FlowLayoutPanel contentPanel;
}

This one accepts the quiz text then splits it with regex and creates the labels and the text boxes. If you are interested to know how to make the Regex return the matches and not only the substrings have a look here

Then in order to take care of the transition between editing, I created an EditableLabel class. It looks like this

class EditableLabel
{
    private string originalText;
    private Label label;
    private TextBox editor;

    public EditableLabel(Label label, TextBox editor)
    {
        this.label = label ?? throw new ArgumentNullException(nameof(label));
        this.editor = editor ?? throw new ArgumentNullException(nameof(editor));
        originalText = label.Text;

        using (Graphics g = label.CreateGraphics())
        {
            this.editor.Width = (int)g.MeasureString("M", this.editor.Font).Width * label.Text.Length;
        }

        editor.LostFocus += (s, e) => SetText();
        editor.KeyUp += (s, e) =>
        {
            if (e.KeyCode == Keys.Enter)
            {
                SetText();
            }
        };

        label.Click += (s, e) =>
        {
            Swap(label, editor);
            this.editor.Focus();
        };
    }

    private void SetText()
    {
        Swap(editor, label);
        string editorText = editor.Text.Trim();
        label.Text = editorText.Length == 0 ? originalText : editorText;
        using (Graphics g = label.CreateGraphics())
        {
            SizeF textSize = g.MeasureString(label.Text, label.Font);
            label.Width = (int)textSize.Width - 4;
        }
    }

    private void Swap(Control original, Control replacement)
    {
        var panel = original.Parent;
        int index = panel.Controls.IndexOf(original);
        panel.Controls.Remove(original);
        panel.Controls.Add(replacement);
        panel.Controls.SetChildIndex(replacement, index);
    }
}

You can use the custom UserControl by drag and dropping it from the designer (after you successfully build) or add it like this:

public partial class Form1 : Form
{
    private WordEditControl wordEditControl1;
    public Form1()
    {
        InitializeComponent();
        wordEditControl1 = new WordEditControl();
        wordEditControl1.SetQuizText("The quick brown fox j___ed over the l__y hound");
        Controls.Add(wordEditControl1)
    }
}

The end result will look like that:

Word quiz form

Pros and Cons

What I consider good with this solution:

  • it's flexible since you can give special treatment to the editable label. You can change its color like I did here, put a context menu with actions like "Clear", "Evaluate", "Show Answer" etc.

  • It's almost multiline. The flow layout panel takes care of the component wrapping and it will work if there are frequent breaks in the quiz string. Otherwise you will have a very big label that won't fit in the panel. You can though use a trick to circumvent that and use \nto break long strings. You can handle \n in the SetQuizText() but I'll leave that to you :) Have in mind that id you don't handle it the label will do and that won't bind well with the FlowLayoutPanel.

  • TextBoxes can fit better. The editing text box that will fit 3 characters will not have the same with as the label with 3 characters. With this solution you don't have to bother with that. Once the edited label is replaced by the text box, the next Controls will shift to the right to fit the text box. Once the label comes back, the other controls can realign.

What I don't like though is that all these will come for a price: you have to manually align the controls. That's why you see some magic numbers (which I don't like and try hard to avoid them). Text box does not have the same height as the label. That's why I've padded all labels 3 pixels on the top. Also for some reason that I don't have time to investigate now, MeasureString() does not return the exact width, it's tiny bit wider. With trial and error I realised that removing 4 pixels will better align the labels

Now you say there will be 300 strings so I guess you mean 300 "quizes". If these are as small as the quick brown fox, I think the way multiline is handled in my solution will not cause you any trouble. But if the text will be bigger I would suggest you go with one of the other answers that work with multiline text boxes.

Have in mind though that if this grows more complex, like for example fancy indicators that the text was right or wrong or if you want the control to be responsive to size changes, then you will need text controls that are not provided by the framework. Windows forms library has unfortunately remained stagnant for several years now, and elegant solutions in problems such as yours are difficult the be found, at least without commercial controls.

Hope it helps you getting started.

Stelios Adamantidis
  • 1,866
  • 21
  • 36
1

I've worked up a bit easier solution to understand that might help you get started at the very least (I didn't have time to play with multiple inputs in the same label, but I got it working correctly for 1).

private void Form1_Load()
{
            for (var i = 0; i < 20; i++)
            {
                Label TemporaryLabel = new Label();
                TemporaryLabel.AutoSize = false;
                TemporaryLabel.Size = new Size(flowLayoutPanel1.Width, 50);
                TemporaryLabel.Text = "This is a ______ message";
                string SubText = "";
                var StartIndex = TemporaryLabel.Text.IndexOf('_');
                var EndIndex = TemporaryLabel.Text.LastIndexOf('_');
                if ((StartIndex != -1 && EndIndex != -1) && EndIndex > StartIndex)
                {
                    string SubString = TemporaryLabel.Text.Substring(StartIndex, EndIndex - StartIndex);
                    SizeF nSize = Measure(SubString);
                    TextBox TemporaryBox = new TextBox();
                    TemporaryBox.Size = new Size((int)nSize.Width, 50);
                    TemporaryLabel.Controls.Add(TemporaryBox);
                    TemporaryBox.Location = new Point(TemporaryBox.Location.X + (int)Measure(TemporaryLabel.Text.Substring(0, StartIndex - 2)).Width, TemporaryBox.Location.Y);
                }
                else continue;
                flowLayoutPanel1.Controls.Add(TemporaryLabel);
            }
} 

EDIT: Forgot the to include the "Measure" method:

private SizeF Measure(string Data)
{
    using (var BMP = new Bitmap(1, 1))
    {
        using (Graphics G = Graphics.FromImage(BMP))
        {
            return G.MeasureString(Data, new Font("segoe ui", 11, FontStyle.Regular));
        }
    }
}

The result:

enter image description here

Then you should be able to assign event handlers to the individual text boxes/name them for easier access later on when the user interacts with the given input.

Dr Archer
  • 348
  • 1
  • 3
  • 14
1

I would try something like this (sure will need some sizes adjustments):

    var indexOfCompletionString = label.Text.IndexOf("____");
    var labelLeftPos = label.Left;
    var labelTopPos =  label.Top;

    var completionStringMeasurments = this.CreateGraphics().MeasureString("____", label.Font);
    var substr = label.Text.Substring(0, indexOfCompletionString);
    var substrMeasurments =  this.CreateGraphics().MeasureString(substr, label.Font);

    var tBox = new TextBox
    {
        Height = (int)completionStringMeasurments.Height,
        Width = (int)completionStringMeasurments.Width,
        Location = new Point(labelLeftPos + (int)substrMeasurments.Width, labelTopPos)
    };

    tBox.BringToFront();
    Controls.Add(tBox);
    Controls.SetChildIndex(tBox, 0);
igorc
  • 2,024
  • 2
  • 17
  • 29
1
    Private Sub MainForm_Load(sender As Object, e As EventArgs) Handles MyBase.Load
    Me.Controls.Add(New TestTextBox With {.Text = "The quick brown fox j___ed over the l__y hound", .Dock = DockStyle.Fill, .Multiline = True})
End Sub



Public Class TestTextBox
    Inherits Windows.Forms.TextBox
    Protected Overrides Sub OnKeyDown(e As KeyEventArgs)
        Dim S = Me.SelectionStart
        Me.SelectionStart = ReplceOnlyWhatNeeded(Me.SelectionStart, (Chr(e.KeyCode)))
        e.SuppressKeyPress = True ' Block Evrything 
    End Sub


    Public Overrides Property Text As String
        Get
            Return MyBase.Text
        End Get
        Set(value As String)
            'List Of Editable Symbols 
            ValidIndex.Clear()
            For x = 0 To value.Length - 1
                If value(x) = DefaultMarker Then ValidIndex.Add(x)
            Next
            MyBase.Text = value
            Me.SelectionStart = Me.ValidIndex.First
        End Set
    End Property

    '---------------------------------------
    Private DefaultMarker As Char = "_"
    Private ValidIndex As New List(Of Integer)
    Private Function ReplceOnlyWhatNeeded(oPoz As Integer, oInputChar As Char) As Integer
        'Replece one symbol in string at pozition, in case delete put default marker
        If Me.ValidIndex.Contains(Me.SelectionStart) And (Char.IsLetter(oInputChar) Or Char.IsNumber(oInputChar)) Then
            MyBase.Text = MyBase.Text.Insert(Me.SelectionStart, oInputChar).Remove(Me.SelectionStart + 1, 1) ' Replece in Output String new symbol
        ElseIf Me.ValidIndex.Contains(Me.SelectionStart) And Asc(oInputChar) = 8 Then
            MyBase.Text = MyBase.Text.Insert(Me.SelectionStart, DefaultMarker).Remove(Me.SelectionStart + 1, 1) ' Add Blank Symbol when backspace
        Else
            Return Me.ValidIndex.First  'Avrything else not allow
        End If
        'Return Next Point to edit 
        Dim Newpoz As Integer? = Nothing
        For Each x In Me.ValidIndex
            If x > oPoz Then
                Return x
                Exit For
            End If
        Next
        Return Me.ValidIndex.First
    End Function

End Class

U Dont Need Label and text Box for this, u can do it in any display it in any string control. Only u need user input position, string what u wanna change with symbols as place holder and input character, is sample on text box, at key input so u number of controls is not imported. For long string copy u can always for each char.