Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unable to Calculate Position within Owner-Draw Text

I'm trying to use Visual Studio 2012 to create a Windows Forms application that can place the caret at the current position within a owner-drawn string. However, I've been unable to find a way to accurately calculate that position.

I've done this successfully before in C++. I've tried numerous methods in C# but have not yet been able to position the caret accurately. Originally, I tried using .NET classes to determine the correct position, but then I tried accessing the Windows API directly. In some cases, I came close, but after some time I still cannot place the caret accurately.

I've created a small test program and posted key parts below. I've also posted the entire project here.

The exact font used is not important to me; however, my application assumes a mono-spaced font. Any help is appreciated.

Form1.cs This is my main form.

public partial class Form1 : Form
{
    private string TestString;
    private int AveCharWidth;
    private int Position;

    public Form1()
    {
        InitializeComponent();
        TestString = "123456789012345678901234567890123456789012345678901234567890";
        AveCharWidth = GetFontWidth();
        Position = 0;
    }

    private void Form1_Load(object sender, EventArgs e)
    {
        Font = new Font(FontFamily.GenericMonospace, 12, FontStyle.Regular, GraphicsUnit.Pixel);
    }

    protected override void OnGotFocus(EventArgs e)
    {
        Windows.CreateCaret(Handle, (IntPtr)0, 2, (int)Font.Height);
        Windows.ShowCaret(Handle);
        UpdateCaretPosition();
        base.OnGotFocus(e);
    }

    protected void UpdateCaretPosition()
    {
        Windows.SetCaretPos(Padding.Left + (Position * AveCharWidth), Padding.Top);
    }

    protected override void OnLostFocus(EventArgs e)
    {
        Windows.HideCaret(Handle);
        Windows.DestroyCaret();
        base.OnLostFocus(e);
    }

    protected override void OnPaint(PaintEventArgs e)
    {
        e.Graphics.DrawString(TestString, Font, SystemBrushes.WindowText,
            new PointF(Padding.Left, Padding.Top));
    }

    protected override bool IsInputKey(Keys keyData)
    {
        switch (keyData)
        {
            case Keys.Right:
            case Keys.Left:
                return true;
        }
        return base.IsInputKey(keyData);
    }

    protected override void OnKeyDown(KeyEventArgs e)
    {
        switch (e.KeyCode)
        {
            case Keys.Left:
                Position = Math.Max(Position - 1, 0);
                UpdateCaretPosition();
                break;
            case Keys.Right:
                Position = Math.Min(Position + 1, TestString.Length);
                UpdateCaretPosition();
                break;
        }
        base.OnKeyDown(e);
    }

    protected int GetFontWidth()
    {
        int AverageCharWidth = 0;

        using (var graphics = this.CreateGraphics())
        {
            try
            {
                Windows.TEXTMETRIC tm;
                var hdc = graphics.GetHdc();
                IntPtr hFont = this.Font.ToHfont();
                IntPtr hOldFont = Windows.SelectObject(hdc, hFont);
                var a = Windows.GetTextMetrics(hdc, out tm);
                var b = Windows.SelectObject(hdc, hOldFont);
                var c = Windows.DeleteObject(hFont);
                AverageCharWidth = tm.tmAveCharWidth;
            }
            catch
            {
            }
            finally
            {
                graphics.ReleaseHdc();
            }
        }
        return AverageCharWidth;
    }
}

Windows.cs Here are my Windows API declarations.

public static class Windows
{
    [Serializable, StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
    public struct TEXTMETRIC
    {
        public int tmHeight;
        public int tmAscent;
        public int tmDescent;
        public int tmInternalLeading;
        public int tmExternalLeading;
        public int tmAveCharWidth;
        public int tmMaxCharWidth;
        public int tmWeight;
        public int tmOverhang;
        public int tmDigitizedAspectX;
        public int tmDigitizedAspectY;
        public short tmFirstChar;
        public short tmLastChar;
        public short tmDefaultChar;
        public short tmBreakChar;
        public byte tmItalic;
        public byte tmUnderlined;
        public byte tmStruckOut;
        public byte tmPitchAndFamily;
        public byte tmCharSet;
    }

    [DllImport("user32.dll")]
    public static extern bool CreateCaret(IntPtr hWnd, IntPtr hBitmap, int nWidth, int nHeight);
    [DllImport("User32.dll")]
    public static extern bool SetCaretPos(int x, int y);
    [DllImport("User32.dll")]
    public static extern bool DestroyCaret();
    [DllImport("User32.dll")]
    public static extern bool ShowCaret(IntPtr hWnd);
    [DllImport("User32.dll")]
    public static extern bool HideCaret(IntPtr hWnd);
    [DllImport("gdi32.dll", CharSet = CharSet.Auto)]
    public static extern bool GetTextMetrics(IntPtr hdc, out TEXTMETRIC lptm);
    [DllImport("gdi32.dll")]
    public static extern IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj);
    [DllImport("GDI32.dll")]
    public static extern bool DeleteObject(IntPtr hObject);
}

Edit

The code I've posted has an issue that makes it even more inaccurate. This is a result of trying many different approaches, some more accurate than this. What I'm looking for is a fix that makes it "fully accurate", as it is in my MFC Hex Editor Control in C++.

like image 995
Jonathan Wood Avatar asked Nov 23 '12 16:11

Jonathan Wood


2 Answers

I tried out your GetFontWidth(), and the width of a character returned was 7.
I then tried out TextRenderer.MearureText on varying lengths of text and had values ranging from 14 through to 7.14 for text of length 1 to 50 respectively with an average character width of 7.62988874736612.

Here is the code I used:

var text = "";
var sizes = new System.Collections.Generic.List<double>();
for (int i = 1; i <= 50; i++)
{
    text += (i % 10).ToString();
    var ts = TextRenderer.MeasureText(text, this.Font);
    sizes.Add((ts.Width * 1.0) / text.Length);

}
sizes.Add(sizes.Average());
Clipboard.SetText(string.Join("\r\n",sizes));

Not satisfied with the results of my little 'experiment', I decided to see how the text was rendered onto the form. Below is a screen capture of the form (magnified 8x).

Magnified font measurement

On close inspection, I observed that

  1. There was an amount of separation between the characters. This made the length of a block of text (1234567890) is 74 pixels long.
  2. There is some space (3px) in front of the text being drawn even though the left padding is 0.

What does this mean to you?

  • If you use your code to calculate the width of a font character, you fail to account for the separating space between two characters.
  • Using the TextRenderer.DrawText can give you varying character widths rendering it quite uselesss.

What are your remaining options?

  • The best way I can see out of this is to hard-code the placement of your text. That way you know the position of each character and can accurately place the cursor at any desired location.
    Needless to say, this is likely going to call for a lot of code.
  • Your second option is to run tests like I did to find the length of a block of text and then divide by the length of the block to find the average character width.
    The problem with this is that your code is not likely to scale properly. For example, changing the size of the font or the user's screen DPI can cause a lot of trouble for the program.

Other things I observed

  • The space inserted in-front of the text is equivalent to the width of the caret (2px in my case) plus 1px (Total of 3px).
  • Hard-coding the width of each character to 7.4 works perfectly.
like image 88
Alex Essilfie Avatar answered Oct 20 '22 10:10

Alex Essilfie


You can use the System.Windows.Forms.TextRenderer to in order to draw the string as well as to calculate its metrics. Various method overloads for both operations exist

TextRenderer.DrawText(e.Graphics, "abc", font, point, Color.Black);
Size measure = TextRenderer.MeasureText(e.Graphics, "1234567890", font);

I have made good experiences with TextRenderer and its accuracy.


UPDATE

I determined the font size like this in one of my applications and it worked perfectly

const TextFormatFlags textFormatFlags =
    TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | 
    TextFormatFlags.PreserveGraphicsClipping;

fontSize = TextRenderer.MeasureText(this.g, "_", font, 
                                    new Size(short.MaxValue, short.MaxValue),
                                    textFormatFlags);
height = fontSize.Height;
width = fontSize.Width;

Make sure to use the same format flags for both drawing and measuring.

(This way of determining the font size of cause works only for monospaced fonts.)

like image 34
Olivier Jacot-Descombes Avatar answered Oct 20 '22 09:10

Olivier Jacot-Descombes