1

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();
}
IVSoftware
  • 5,732
  • 2
  • 12
  • 23
  • Has ClearType anything to do with that? Try disable it to see if it does. – aybe Dec 13 '22 at 02:16
  • 1
    To note that, since the Win32 TreeView doesn't handle 0-based collections, the ImageList you set is actually duplicated internally, creating a new one, with default values -- The simplest thing you can do (without owner-drawing) is to draw the images you want to add to the ImageList (probably used with this TreeView alone) to a new 32bit Bitmap, sized as the `ImageSize` of the ImageList, `Clear()` the derived Graphics with the TreeView's BackColor and draw the images with this background. Also, important, set the `TransparentColor` of the ImageList to the same color used as background – Jimi Dec 13 '22 at 02:34
  • When you do that, you might as well keep the ColorDepth to 8 bit indexed, it doesn't matter anymore (BTW, the TreeView is meant to handle GIF images as glyphs) – Jimi Dec 13 '22 at 02:42
  • @aybe What you're saying makes sense because it _does_ look the way it might if some kind of anti-aliasing was being applied. But...I must confess that I'm not certain where to go to disable it. It's not a property of the `TreeView` is it part of the graphics context? – IVSoftware Dec 13 '22 at 03:36
  • 1
    Create your images from the glyphs directly. Draw the chars (TextRenderer.DrawText) on transparent bitmaps, pass to the method the glyph as string, glyph's font, foreground color, and the align flags. – dr.null Dec 13 '22 at 03:51
  • 1
    [Test this PasteBin](https://pastebin.com/sxxnbGCf) (see the notes) -- Not sure why you're using the same ImageList and why you're using TextRenderer to render text on a Bitmap. Use `Graphics.DrawString()` with AntiAliasGridFit – Jimi Dec 13 '22 at 07:18
  • Yes, sorry for the `TextRenderer` part. It's `Graphics.DrawString` as mentioned. Gdi+ – dr.null Dec 13 '22 at 14:09

1 Answers1

1

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

dr.null
  • 4,032
  • 3
  • 9
  • 12
  • 1
    Thanks for such a detailed and wide-ranging treatment of this problem domain. It hadn't occurred to me to use `DrawString` but what a great idea for getting the outcome I want; you've really advanced my knowledge here! This answer checks all the boxes, and those `checked` box state images are clear when they draw, too ;) – IVSoftware Dec 14 '22 at 13:37
  • Supplement note: when `TreeView.StateImageList` is set, the images are always rescaled to 16s * 16s, where s is the dpi scaling factor. So, if your monitor is 125% scaling up, you need to prepare 20*20 images for `TreeView.StateImageList`. – Mario Jul 26 '23 at 16:07
  • @Mario Which means that we need to provide an `ImageList` component for each scale and detect the system settings changes to set a proper one? Nope, it doesn't work like that. Please read [High DPI support in Windows Forms](https://learn.microsoft.com/en-us/dotnet/desktop/winforms/high-dpi-support-in-windows-forms?view=netframeworkdesktop-4.8). In fact, you need to read the whole chapter. – dr.null Jul 26 '23 at 17:15
  • The whole chapter only applies for .net framework 4.8.1 or older. For .Net 5.0 and .Net 6.0, the configuration in the article do not work at all. In .Net 7.0, the bahaviour has been further updated (ref: [What’s new in Windows Forms in .NET 7.0](https://devblogs.microsoft.com/dotnet/winforms-enhancements-in-dotnet-7/)). Thus, the only way I find to make state image clear for .Net 6.0 is to rescale it first. – Mario Jul 27 '23 at 01:30