Search code examples
c#winforms.net-6.0system.drawing

State image shows obvious pixel defects when drawing WinForms TreeView


On my brand new Windows 11 desktop, the bitmap I've been using to indicate a 'connected database' state in my stable production app is suddenly looking terrible on my 4K 150% scaled display (the monitor is still the same as before). The issue seems specific to TreeView because the same bitmap on the same Form looks OK when set as the image for a Label for example. It also still looks OK on a Win 10 VM running on the new machine. And it mainly affects the green one. Weird.

comparison


Anyway, I can't just sit and cry about it - I really do need to come up with a new way of drawing this that looks right 100% of the time. So I'm trying a new approach using a glyph font and it looks nice and clear when I put it up on a set of labels.

glyphs

Looking good in the TableLayoutPanel.


What I need to do now is generate an ImageList to use for the tree view, and as a proof of concept I tried using Control.DrawToBitmap to generate a runtime ImageList from the labels. I added a #DEBUG block that saves the bitmaps and I can open them up in MS Paint and they look fine (here greatly magnified of course).

generated bitmaps

Looking good in the .bmp files.


And for sure this improves things, but there are still some obvious pixel defects that look like noisy anti-aliasing or resizing artifacts, even though I'm taking care to use consistent 32 x 32 sizes for everything. I've messed with the ColorDepth ans ImageSize properties of the ImageList. I've wasted hours trying to understand and fix it. It's happening in my production code. It's happening in the minimal reproducible sample I have detailed below. So, before I tear the rest of my hair out, maybe someone can spot what I'm doing wrong, or show me a better way.

artifacts


Here's my code or browse full sample on GitHub.

public partial class HostForm : Form
{
    public HostForm()
    {
        InitializeComponent();
#if DEBUG
        _imgFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Paint")!;
        Directory.CreateDirectory(_imgFolder);
#endif
    }
    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e); 
        BackColor = Color.Teal;
        var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Fonts", "database.ttf")!;
        privateFontCollection.AddFontFile(path);

        var fontFamily = privateFontCollection.Families[0];
        var font = new Font(fontFamily, 13.5F);
        var backColor = Color.FromArgb(128, Color.Teal);
        tableLayoutPanel.BackColor = backColor;

        // Stage the glyphs in the TableLayoutPanel.
        setLabelAttributes(label: label0, font: font, text: "\uE800", foreColor: Color.LightGray, backColor: backColor);
        setLabelAttributes(label: label1, font: font, text: "\uE800", foreColor: Color.LightSalmon, backColor: backColor);
        setLabelAttributes(label: label2, font: font, text: "\uE800", foreColor: Color.LightGreen, backColor: backColor);
        setLabelAttributes(label: label3, font: font, text: "\uE800", foreColor: Color.Blue, backColor: backColor);
        setLabelAttributes(label: label4, font: font, text: "\uE800", foreColor: Color.Gold, backColor: backColor);
        setLabelAttributes(label: label5, font: font, text: "\uE801", foreColor: Color.LightGray, backColor: backColor);
        setLabelAttributes(label: label6, font: font, text: "\uE801", foreColor: Color.LightSalmon, backColor: backColor); 
        setLabelAttributes(label: label7, font: font, text: "\uE801", foreColor: Color.LightGreen, backColor: backColor);
        setLabelAttributes(label: label8, font: font, text: "\uE801", foreColor: Color.Blue, backColor: backColor);
        setLabelAttributes(label: label9, font: font, text: "\uE801", foreColor: Color.Gold, backColor: backColor);
        setLabelAttributes(label: label10, font: font, text: "\uE803", foreColor: Color.LightGray, backColor: backColor);
        setLabelAttributes(label: label11, font: font, text: "\uE803", foreColor: Color.LightSalmon, backColor: backColor);
        setLabelAttributes(label: label12, font: font, text: "\uE803", foreColor: Color.LightGreen, backColor: backColor);
        setLabelAttributes(label: label13, font: font, text: "\uE803", foreColor: Color.Blue, backColor: backColor);
        setLabelAttributes(label: label14, font: font, text: "\uE802", foreColor: Color.LightGray, backColor: backColor);
        setLabelAttributes(label: label15, font: font, text: "\uE804", foreColor: Color.LightGreen, backColor: backColor);

        makeRuntimeImageList();
    }        
    private void setLabelAttributes(Label label, Font font, string text, Color foreColor, Color backColor)
    {
        label.UseCompatibleTextRendering = true;
        label.Font = font;
        label.Text = text;
        label.ForeColor = foreColor;
        label.BackColor = Color.FromArgb(200, backColor);
    }
    private void makeRuntimeImageList()
    {
        var imageList22 = new ImageList(this.components);
        imageList22.ImageSize = new Size(32, 32);
        imageList22.ColorDepth = ColorDepth.Depth8Bit;
        foreach (
            var label in 
            tableLayoutPanel.Controls
            .Cast<Control>()
            .Where(_=>_ is Label)
            .OrderBy(_=>int.Parse(_.Name.Replace("label", string.Empty))))
        {
            Bitmap bitmap = new Bitmap(label.Width, label.Height);
            label.DrawToBitmap(bitmap, label.ClientRectangle);
            imageList22.Images.Add(bitmap);
#if DEBUG
            bitmap.Save(Path.Combine(_imgFolder, $"{label.Name}.{ImageFormat.Bmp}"), ImageFormat.Bmp);
#endif
        }
        this.treeView.StateImageList = imageList22;
    }

#if DEBUG
    readonly string _imgFolder;
#endif
    PrivateFontCollection privateFontCollection = new PrivateFontCollection();
}

Solution

  • If you want to use the TreeView.StateImageList rather than the TreeView.ImageList, then you need to have/create 16x16 images. Setting the size of the ImageSize property to larger or smaller sizes does nothing but outputs blurred, distorted overlapping pixels images. Because with TreeView.StateImageList, the non-16x16 images will be resized to fit the allocated spaces, the bounds of the CheckBoxes since the common use of the TreeView.StateImageList is to use the first (unchecked) and second (checked) images of the list to indicate the nodes checked state of a TreeView with CheckBoxes property is set to true.

    From the docs

    The state images displayed in the TreeView are 16 x 16 pixels by default. Setting the ImageSize property of the StateImageList will have no effect on how the images are displayed. However, the state images are resized according to the system DPI setting when the app.config file contains the following entry:

    <appSettings>  
      <add key="EnableWindowsFormsHighDpiAutoResizing" value="true" />  
    </appSettings>  
    

    ... and

    When the CheckBoxes property of a TreeView is set to true and the StateImageList property is set, each TreeNode that is contained in the TreeView displays the first and second images from the StateImageList to indicate an unchecked or checked state, respectively.

    Taking that into account to convert font glyphs to images will result acceptable quality. Make sure to choose a proper font size to sharpen the glyph's details.

    Example

    Here's a simple helper class to convert some of the Segoe MDL2 Assets font glyphs to images.

    public static class SegoeMDL2AssetsFont
    {
        public enum Glyph
        {
            Video = 0xE714,
            Search = 0xE721,
            FavoriteStar = 0xE734,
            FavoriteStarFill = 0xE735,
            GripperTool = 0xE75E,
            ContactPresence = 0xE8CF,
            Like = 0xE8E1,
            FeedbackApp = 0xE939,
            Robot = 0xE99A
        }
    
        public static Bitmap CreateGlyphImage(
            Glyph glyph, 
            Size imgSize,
            float fontSize, 
            FontStyle fontStyle,
            Color foreColor,
            Color backColor)
        {            
            var bmp = new Bitmap(imgSize.Width, imgSize.Height);
            var str = ((char)(int)glyph).ToString();
            var rec = new Rectangle(Point.Empty, imgSize);
                
            using (var g = Graphics.FromImage(bmp))
            using (var fnt = GetFont(fontSize, fontStyle))
            using (var sf = new StringFormat(StringFormat.GenericTypographic))
            using (var br = new SolidBrush(foreColor))
            {               
                sf.Alignment = sf.LineAlignment = StringAlignment.Center;
    
                g.Clear(backColor);
                g.TextRenderingHint = TextRenderingHint.AntiAliasGridFit;
                g.DrawString(str, fnt, br, rec, sf);
            }
    
            return bmp;
        }
    
        public static string FontName => "Segoe MDL2 Assets";
    
        public static Font GetFont(float fontSize, FontStyle style) =>
            new Font(FontName, fontSize, style);        
    }
    

    ... and the implementation.

    private void SomeCaller()
    {
        imgList.Images.Clear();
        imgList.ColorDepth = ColorDepth.Depth32Bit;
        imgList.ImageSize = new Size(16, 16);
        imgList.TransparentColor = Color.Teal;
    
        var sz = imgList.ImageSize;
        var fs = 12f;
        var style = FontStyle.Regular;
        var bkColor = imgList.TransparentColor;
    
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.FavoriteStar,
            sz, fs, style, Color.LightGray, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.FavoriteStarFill,
            sz, fs, style, Color.LightSalmon, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.GripperTool,
            sz, fs, style, Color.LightGreen, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.Search,
            sz, fs, style, Color.Blue, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.Video,
            sz, fs, style, Color.Gold, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.ContactPresence,
            sz, fs, style, Color.DarkRed, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.Like,
            sz, fs, style, Color.Cyan, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.Robot,
            sz, fs, style, Color.Maroon, bkColor));
        imgList.Images.Add(SegoeMDL2AssetsFont.CreateGlyphImage(
            SegoeMDL2AssetsFont.Glyph.FeedbackApp,
            sz, fs, style, Color.DarkOrange, bkColor));
    
        treeView1.StateImageList = imgList;
        treeView1.BeginUpdate();
        treeView1.Nodes.Clear();
    
        for (int i = 0; i < imgList.Images.Count; i++)
        {
            var tn = new TreeNode($"Node {i + 1}")
            {
                StateImageIndex = i
            };
            treeView1.Nodes.Add(tn);
        }
    
        treeView1.EndUpdate();
    }
    

    ... the result

    SO74778819A