Tuesday 23 April 2013

Browsing for files and folders - a C# wrapper for SHBrowseForFolder

In an IronPython application I've been working on, I need to show the user a dialog that allows him or her to select either a file or a directory. I think there are legitimate use cases for this, but doing it on Windows turns out to be much more trouble than you might reasonably expect. Since this is an IronPython application, I have access to the Windows Forms API, but the long and short of it is that there isn't a standard Windows Forms component that gives you a good way of doing this. The FolderBrowserDialog will only let you browse for folders. The closest you can get is to do what this article suggests and use the OpenFileDialog with its FileName property given the magic value of "Folder Selection." plus some other settings, and this will sort of do what you want, but a) you have the string "Folder Selection." displayed in your 'File name' edit box, and b) if you click once on a file its name is put into the edit box, overwriting your magic "Folder Selection." string which means that you can't now select a folder. This seems unintuitive and kludgy to me. It is, however, the approach taken by WinMerge


so if it's good enough for them then it could well be good enough for you. But it does suck.

There is another approach which is to use the unmanaged SHBrowseForFolder function which shows essentially the same dialog as the WinForms FolderBrowserDialog, but unlike the FolderBrowserDialog it allows you to select either folders or files at the same time. Now I needed a .NET version of this since I'm using IronPython, and ideally a C# version since all the rest of my GUI is written in that language, and it turns out that there are some very good examples on the web already, in particular:

http://support.microsoft.com/default.aspx?scid=kb;[LN];306285
http://www.codeproject.com/Articles/3551/C-does-Shell-Part-1
and
http://dotnetzip.codeplex.com/SourceControl/changeset/view/29832#432677

However, there were two additional issues I wanted to address, and none of the examples I looked at dealt with them fully. Firstly, I wanted to be able to set the initially selected directory in the dialog when it is first shown, like the InitialDirectory property of the OpenFileDialog. Secondly, there is a bug in the dialog shown by SHBrowseForFolder, whereby on Windows 7 it doesn't always scroll to ensure the visibility of the selected item. This is quite well known, and doesn't seem likely to ever get fixed by Microsoft. It's quite annoying, and means that when I set an initial directory, then show the dialog, the folder I've selected is often not in view (it appears to be random as to whether it scrolls to it or not when the dialog is shown). There are workarounds suggested in several forums but I didn't find a working solution in C#. So I've put together my own simple wrapper for SHBrowseForFolder in C#, with an 'InitialDirectory' property and a workaround for the not-ensuring-visibility-of-selected-item bug. This is mostly based on the MS article mentioned above and uses the fix suggested here for the scrolling problem. Basically we just force the dialog to scroll the selected item into view whenever it changes. This results in the dialog visibly scrolling on its own, particularly when it's first shown, which isn't ideal, but is better than the selected item not being visible.

Anyway, here's what it looks like:


Not exactly a thing of beauty, but really when it comes to showing a file and folder browser on Windows you are left trying to decide which is the least-bad option.

And here's the code:

/*
 * This code is distributed under the terms and conditions of the MIT License.
 * 
 * http://opensource.org/licenses/mit-license.php
 * 
 * Copyright (c) 2013 John Dickinson
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy 
 * of this software and associated documentation files (the "Software"), to deal 
 * in the Software without restriction, including without limitation the rights 
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
 * copies of the Software, and to permit persons to whom the Software is 
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all 
 * copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
 * SOFTWARE.
 */

using System;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;

namespace FolderBrowserTest
{

    /// <summary>
    /// A dialog to browse for folders (and optionally files) that wraps the unmanaged SHBrowseForFolder function.
    /// </summary>
    /// <remarks>
    /// This is mostly based on the example given here: http://support.microsoft.com/default.aspx?scid=kb;[LN];306285
    /// with the main differences being that a) it allows the user to set an initial directory for the dialog, and
    /// b) it does something to address the bug in Win 7 (and above?) whereby the SHBrowseForFolder dialog won't
    /// always scroll to ensure visibility of the current selection.
    /// 
    /// Example usage:
    /// 
    /// FolderBrowser fb = new FolderBrowser();
    /// fb.Description = "Please select a file or folder below:";
    /// fb.IncludeFiles = true;
    /// fb.InitialDirectory = @"C:\Program Files\Windows Media Player";
    /// if (fb.ShowDialog() == DialogResult.OK)
    /// {
    ///     string userSelection = fb.SelectedPath;
    ///     ...
    ///     
    /// </remarks>
    public class FolderBrowser
    {

        // C# representation of the IMalloc interface.
        [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("00000002-0000-0000-C000-000000000046")]
        private interface IMalloc
        {
            [PreserveSig]
            IntPtr Alloc([In] int cb);
            [PreserveSig]
            IntPtr Realloc([In] IntPtr pv, [In] int cb);
            [PreserveSig]
            void Free([In] IntPtr pv);
            [PreserveSig]
            int GetSize([In] IntPtr pv);
            [PreserveSig]
            int DidAlloc(IntPtr pv);
            [PreserveSig]
            void HeapMinimize();
        }

        // C# representation of struct containing scroll bar parameters
        [Serializable, StructLayout(LayoutKind.Sequential)]
        private struct SCROLLINFO
        {
            public uint cbSize;
            public uint fMask;
            public int nMin;
            public int nMax;
            public uint nPage;
            public int nPos;
            public int nTrackPos;
        }

        // Styles affecting the appearance and behaviour of the browser dialog. This is a subset of the styles 
        // available as we're not exposing the full list of options in this simple wrapper.
        // See http://msdn.microsoft.com/en-us/library/windows/desktop/bb773205%28v=vs.85%29.aspx for the complete
        // list.
        private enum BffStyles
        {
            ShowEditBox = 0x0010, // BIF_EDITBOX
            ValidateSelection = 0x0020, // BIF_VALIDATE
            NewDialogStyle = 0x0040, // BIF_NEWDIALOGSTYLE
            UsageHint = 0x0100, // BIF_UAHINT
            NoNewFolderButton = 0x0200, // BIF_NONEWFOLDERBUTTON
            IncludeFiles = 0x4000, // BIF_BROWSEINCLUDEFILES
        }

        // Delegate type used in BROWSEINFO.lpfn field.
        private delegate int BFFCALLBACK(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);

        // Struct to pass parameters to the SHBrowseForFolder function.
        [StructLayout(LayoutKind.Sequential, Pack = 8)]
        private struct BROWSEINFO
        {
            public IntPtr hwndOwner;
            public IntPtr pidlRoot;
            public IntPtr pszDisplayName;
            [MarshalAs(UnmanagedType.LPTStr)]
            public string lpszTitle;
            public int ulFlags;
            [MarshalAs(UnmanagedType.FunctionPtr)]
            public BFFCALLBACK lpfn;
            public IntPtr lParam;
            public int iImage;
        }

        [DllImport("User32.DLL")]
        private static extern IntPtr GetActiveWindow();

        [DllImport("Shell32.DLL")]
        private static extern int SHGetMalloc(out IMalloc ppMalloc);

        [DllImport("Shell32.DLL")]
        private static extern int SHGetSpecialFolderLocation(IntPtr hwndOwner, int nFolder, out IntPtr ppidl);

        [DllImport("Shell32.DLL")]
        private static extern int SHGetPathFromIDList(IntPtr pidl, StringBuilder Path);

        [DllImport("Shell32.DLL", CharSet = CharSet.Auto)]
        private static extern IntPtr SHBrowseForFolder(ref BROWSEINFO bi);

        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool GetScrollInfo(IntPtr hwnd, int fnBar, ref SCROLLINFO lpsi);

        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        private static extern IntPtr SendMessage(HandleRef hWnd, int msg, int wParam, string lParam);

        [DllImport("user32.dll", CharSet = CharSet.Auto)]
        private static extern IntPtr SendMessage(HandleRef hWnd, int msg, int wParam, IntPtr lParam);

        [DllImport("user32.dll")]
        private static extern bool PostMessage(IntPtr hWnd, uint Msg, int wParam, int lParam);

        [DllImport("user32.dll", SetLastError = true)]
        private static extern IntPtr FindWindowEx(IntPtr parentHandle, IntPtr childAfter, string className, string windowTitle);

        private static readonly int MAX_PATH = 260;

        /// <summary>
        /// Helper function that returns the IMalloc interface used by the shell.
        /// </summary>
        private static IMalloc GetSHMalloc()
        {
            IMalloc malloc;
            SHGetMalloc(out malloc);
            return malloc;
        }

        /// <summary>
        /// Enum of CSIDLs identifying standard shell folders.
        /// </summary>
        private enum FolderID
        {
            Desktop = 0x0000,
            Printers = 0x0004,
            MyDocuments = 0x0005,
            Favorites = 0x0006,
            Recent = 0x0008,
            SendTo = 0x0009,
            StartMenu = 0x000b,
            MyComputer = 0x0011,
            NetworkNeighborhood = 0x0012,
            Templates = 0x0015,
            MyPictures = 0x0027,
            NetAndDialUpConnections = 0x0031,
        }

        // Constants for sending and receiving messages in BrowseCallBackProc.
        private const int WM_USER = 0x400;
        private const int BFFM_INITIALIZED = 1;
        private const int BFFM_SELCHANGED = 2;
        private const int BFFM_SETSELECTIONW = WM_USER + 103;
        private const int BFFM_SETEXPANDED = WM_USER + 106;

        // Constants for sending messages to a Tree-View Control.
        private const int TV_FIRST = 0x1100;
        private const int TVM_GETNEXTITEM = (TV_FIRST + 10);
        private const int TVGN_ROOT = 0x0;
        private const int TVGN_CHILD = 0x4;
        private const int TVGN_NEXTVISIBLE = 0x6;
        private const int TVGN_CARET = 0x9;

        // Constants defining scroll bar parameters to set or retrieve.
        private const int SIF_RANGE = 0x1;
        private const int SIF_PAGE = 0x2;
        private const int SIF_POS = 0x4;

        // Identifies Vertical Scrollbar.
        private const int SB_VERT = 0x1;

        // Used for vertical scroll bar message.
        private const int SB_LINEUP = 0;
        private const int SB_LINEDOWN = 1;
        private const int WM_VSCROLL = 0x115;

        // Root node of the tree view.
        private FolderID rootLocation = FolderID.Desktop;

        /// <summary>
        /// Gets or sets the descriptive text displayed above the tree view control in the dialog box.
        /// </summary>
        public string Description { get; set; }

        /// <summary>
        /// Gets or sets the initial directory displayed by the dialog box.
        /// </summary>
        public string InitialDirectory { get; set; }

        /// <summary>
        /// Gets or sets the path selected by the user.
        /// </summary>
        public string SelectedPath { get; set; }

        /// <summary>
        /// Gets or sets whether the dialog box will use the new style.
        /// </summary>
        public bool NewStyle { get; set; }

        /// <summary>
        /// Gets or sets whether the dialog box can be used to select files as well as folders.
        /// </summary>
        public bool IncludeFiles { get; set; }

        /// <summary>
        /// Gets or sets whether to include an edit control in the dialog box that allows the user to type the name of an item.
        /// </summary>
        public bool ShowEditBox { get; set; }

        /// <summary>
        /// Gets or sets whether to include the New Folder button in the dialog box.
        /// </summary>
        public bool ShowNewFolderButton { get; set; }

        public FolderBrowser()
        {
            Description = "Please select a folder below:";
            InitialDirectory = String.Empty;
            SelectedPath = String.Empty;
            NewStyle = true;
            IncludeFiles = false;
            ShowEditBox = false;
            ShowNewFolderButton = true;
        }

        /// <summary>
        /// Creates flags for BROWSEINFO.ulFlags based on the values of boolean member properties.
        /// </summary>
        private int GetFlags()
        {
            int ret_val = 0;
            if (NewStyle) ret_val |= (int)BffStyles.NewDialogStyle;
            if (IncludeFiles) ret_val |= (int)BffStyles.IncludeFiles;
            if (!ShowNewFolderButton) ret_val |= (int)BffStyles.NoNewFolderButton;
            if (ShowEditBox) ret_val |= (int)BffStyles.ShowEditBox;
            return ret_val;
        }

        /// <summary>
        /// Shows the folder browser dialog box.
        /// </summary>
        public DialogResult ShowDialog()
        {
            return ShowDialog(null);
        }

        /// <summary>
        /// Shows the folder browser dialog box with the specified owner window.
        /// </summary>
        public DialogResult ShowDialog(IWin32Window owner)
        {
            IntPtr pidlRoot = IntPtr.Zero;

            // Get/find an owner HWND for this dialog.
            IntPtr hWndOwner;

            if (owner != null)
            {
                hWndOwner = owner.Handle;
            }
            else
            {
                hWndOwner = GetActiveWindow();
            }

            // Get the IDL for the specific startLocation.
            SHGetSpecialFolderLocation(hWndOwner, (int)rootLocation, out pidlRoot);

            if (pidlRoot == IntPtr.Zero)
            {
                return DialogResult.Cancel;
            }

            int flags = GetFlags();

            if ((flags & (int)BffStyles.NewDialogStyle) != 0)
            {
                if (System.Threading.ApartmentState.MTA == Application.OleRequired())
                    flags = flags & (~(int)BffStyles.NewDialogStyle);
            }

            IntPtr pidlRet = IntPtr.Zero;

            try
            {
                // Construct a BROWSEINFO.
                BROWSEINFO bi = new BROWSEINFO();
                IntPtr buffer = Marshal.AllocHGlobal(MAX_PATH);

                bi.pidlRoot = pidlRoot;
                bi.hwndOwner = hWndOwner;
                bi.pszDisplayName = buffer;
                bi.lpszTitle = Description;
                bi.ulFlags = flags;
                bi.lpfn = new BFFCALLBACK(FolderBrowserCallback);

                // Show the dialog.
                pidlRet = SHBrowseForFolder(ref bi);

                // Free the buffer you've allocated on the global heap.
                Marshal.FreeHGlobal(buffer);

                if (pidlRet == IntPtr.Zero)
                {
                    // User clicked Cancel.
                    return DialogResult.Cancel;
                }

                // Then retrieve the path from the IDList.
                StringBuilder sb = new StringBuilder(MAX_PATH);
                if (0 == SHGetPathFromIDList(pidlRet, sb))
                {
                    return DialogResult.Cancel;
                }

                // Convert to a string.
                SelectedPath = sb.ToString();
            }
            finally
            {
                IMalloc malloc = GetSHMalloc();
                malloc.Free(pidlRoot);

                if (pidlRet != IntPtr.Zero)
                {
                    malloc.Free(pidlRet);
                }
            }
            return DialogResult.OK;
        }

        private int FolderBrowserCallback(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData)
        {
            if (uMsg == BFFM_INITIALIZED && InitialDirectory != "")
            {
                // We get in here when the dialog is first displayed. If an initial directory
                // has been specified we will make this the selection now, and also make sure
                // that directory is expanded.
                HandleRef h = new HandleRef(null, hwnd);
                SendMessage(h, BFFM_SETSELECTIONW, 1, InitialDirectory);
                SendMessage(h, BFFM_SETEXPANDED, 1, InitialDirectory);
            }
            else if (uMsg == BFFM_SELCHANGED)
            {
                // We get in here whenever the selection in the dialog box changes. To cope with the bug in Win7 
                // (and above?) whereby the SHBrowseForFolder dialog won't always scroll the selection into view (see 
                // http://social.msdn.microsoft.com/Forums/en-US/vcgeneral/thread/a22b664e-cb30-44f4-bf77-b7a385de49f3/)
                // we follow the suggestion here: 
                // http://www.codeproject.com/Questions/179097/SHBrowseForFolder-and-SHGetPathFromIDList
                // to adjust the scroll position when the selection changes.
                IntPtr hbrowse = FindWindowEx(hwnd, IntPtr.Zero, "SHBrowseForFolder ShellNameSpace Control", null);
                IntPtr htree = FindWindowEx(hbrowse, IntPtr.Zero, "SysTreeView32", null);
                IntPtr htir = SendMessage(new HandleRef(null, htree), TVM_GETNEXTITEM, TVGN_ROOT, IntPtr.Zero);
                IntPtr htis = SendMessage(new HandleRef(null, htree), TVM_GETNEXTITEM, TVGN_CARET, IntPtr.Zero);
                IntPtr htic = SendMessage(new HandleRef(null, htree), TVM_GETNEXTITEM, TVGN_CHILD, htir);
                int count = 0;
                int pos = 0;
                for (; (int)htic != 0; htic = SendMessage(new HandleRef(null, htree), TVM_GETNEXTITEM, TVGN_NEXTVISIBLE, htic), count++)
                {
                    if (htis == htic)
                        pos = count;
                }
                SCROLLINFO si = new SCROLLINFO();
                si.cbSize = (uint)Marshal.SizeOf(si);
                si.fMask = SIF_POS | SIF_RANGE | SIF_PAGE;
                GetScrollInfo(htree, SB_VERT, ref si);
                si.nPage /= 2;
                if ((pos > (int)(si.nMin + si.nPage)) && (pos <= (int)(si.nMax - si.nMin - si.nPage)))
                {
                    si.nMax = si.nPos - si.nMin + (int)si.nPage;
                    for (; pos < si.nMax; pos++) PostMessage(htree, WM_VSCROLL, SB_LINEUP, 0);
                    for (; pos > si.nMax; pos--) PostMessage(htree, WM_VSCROLL, SB_LINEDOWN, 0);
                }
            }
            return 0;
        }

    }

}

Personally, I only find the non-scrolling bug to be a problem when the dialog is first shown, as I've just set an initial directory and this may end up not being visible. After that, whenever the selection changes it's because the user has changed it with the mouse or keyboard, so it's hard for the selection to go out of view then. On this basis, there is probably a good argument for only executing the code in the BFFM_SELCHANGED condition when the dialog is first shown, and it should be fairly trivial to add a flag to implement this as an exercise for the reader. Note however that when I've had a play at doing this, we actually go into the BFFM_SELCHANGED case three times when the dialog is first shown, and it's only on the third one that we actually scroll, since before this htic is always zero, so you need to cope with this - i.e. don't set your 'doneScrolling' flag (or whatever) until htic has been non-zero (or until you've gone into the BFFM_SELCHANGED three times).

1 comment:

  1. Hello Mr. Dickinson,

    Thank you for posting your C# wrapper. I've been trying to find a way to create a folder browser dialog that does not crash in Windows Server 2012 r2 Core mode. Yours works well when the NewStyle property is set to false. As well, one of the articles you've attributed will help me modify it to accept file type filters. This was very helpful. Thanks again.

    ReplyDelete