分类: C/C++
2007-10-15 10:08:05
Everyone is familiar with the standard 'File Open' and 'File Save' common dialogs. Microsoft has done a great job of providing advanced functionality for these controls. Each of them is essentially a miniature version of Windows Explorer: in addition to presenting a list of files, the user can drag files within the list, create new folders, rename files, drag-n-drop new files into the dialog from outside, and cut, paste or delete files. These operations are all available right in the middle of Opening or Saving the current file.
But what if you don't want to offer the user that much freedom? The common dialogs offer a lot of powerful features, but is there any way of limiting those features?
In one of our company projects we needed a way to limit a common dialog's functionality to providing a strictly read-only view of files in a folder. This article presents the solution we came up with.
Much of the code presented here was derived from code written by Dino Esposito in an article entitled "The Logo and Beyond: Solutions for Writing Great Windows 2000-based Apps". His original article, including downloadable source code, is available in the , accessible on Microsoft's web site.
Some of the code was also taken from the Code Project article "Customizing the Windows Common File Open Dialog", written by S h a n x. His original article, including downloadable source code, is available here on the CodeProject web site. S h a n x presents a number of interesting techniques for customizing common dialogs in general.
If you'd like an introduction on how to customize common dialogs, I recommend reading the MSDN documentation on the web site. Search for "Customizing Common Dialog Boxes" to uncover useful articles.
We first consider what a "read-only" view of files in a folder involves. This seems like a simple question, but as I discovered, there are subtleties. Several times while writing the code I thought I was finished, but a colleague would come along and ask "What if the user did this?" and point out another loophole. (I think I've got them all covered now :-)
Within a standard 'File Open' common dialog, the following operations are available:
deletes a file to the Recycle Bin. Pressing
deletes a file directly, without going to the Recycle Bin.
allows editing of a file's name. Clicking with the (left) mouse button on a filename that is already highlighted, also allows editing of the file's name.
allows a file to be Cut from its current location and pasted somewhere else.
allows a file to be Pasted into the dialog from another location. This allows new files to be added to the dialog folder, and also overwrites (clobbers) any existing files of the same name already present in the folder.
, allowing files to be added to the dialog folder and clobbering any existing files of the same name.
For a read-only file dialog, each of the above has to be prevented or disabled.
If you're using MFC, a File Open or File Save common dialog is created by instantiating a CFileDialog
object and calling its DoModal()
method. If you're using the Win32 API, the same dialog is created by calling either GetOpenFileName()
or GetSaveFileName()
, as appropriate. In either case the dialog's OPENFILENAME
data member offers a way of hooking the window procedure associated with the dialog. This allows us to tap into the dialog's message stream, providing an entry into customizing the dialog's behavior.
Like all dialogs, a File Open dialog consists of an overall Parent dialog window which contains various child windows as controls. The diagram below illustrates those elements whose behavior has to be altered to make the dialog read-only. Their window class names and parent-child relationships can be discovered by using a spy utility such as Spy++ (included with Visual Studio), or the useful freeware window dowsing tool.
Each of the items shown has to be hooked or subclassed (in the Windows sense) in some way, so as to intercept and defeat its 'undesirable' behaviors.
One point of probable confusion is the role played by the SHELLDLL_DefView
control, shown by the dashed line in the diagram. The window that actually displays the files and folders in the dialog is the SysListView32
(aka 'ListView'). It has a number of set-able styles that determine how the files appear; for example, whether they appear as "Large Icons" or as a "List" or whatever. However the SysListView32
is actually the child of a parent SHELLDLL_DefView
control, which is hidden behind the SysListView32
.
Although the SysListView32
control determines how the files are displayed, many of the actual file operations are handled by the parent SHELLDLL_DefView
control. This is important to understand, because it means that some of the behaviors we want to intercept are handled by the SysListView32
, and some are handled by the SHELLDLL_DefView
. The two windows have different functionalities, and we have to consider both when it comes to detecting which one handles what.
To a seasoned Windows programmer, it might seem simple to trap and discard keystrokes you don't want: just get a handle to the appropriate window (probably the SysListView32
), subclass its window procedure to a new function of your own design, then arrange to have your new function eat unwanted WM_KEYDOWN
messages.
Unfortunately this doesn't work. The problem is that when the common dialog is instantiated, it installs a keyboard hook. This means it sees keyboard messages before they are passed to the dialog window procedure. Subclassing the procedure is therefore of no use: by the time your function sees them, the keystrokes have already been acted on — the file has been deleted, or renamed, or whatever.
To get around this we have to install our own keyboard hook once the dialog has finished initializing. The dialog sends a CDN_INITDONE
notification message once initialization is complete; we install our keyboard hook at that time. Thereafter, it is our own keyboard function that gets to intercept keystrokes before they are passed on to the original hook (and thence on to the dialog procedure).
In the keyboard hook function, we can trap and discard the keystrokes we want to prevent:
,
,
and
. The function contains code like this:
// 'NewKeybdHook' is the custom keyboard hook we installed. UINT CALLBACK NewKeybdHook( int nCode, WPARAM wParam, LPARAM lParam ) { // wParam is the key code. if (wParam == VK_DELETE) // user pressed the Delete key? { return 1; // eat it: don't let dialog box see it }
In principle the same approach could also be used to trap
(edit), but there's an easier way to disable filename editing: change the window style of the SysListView32
, as described next.
Disabling filename edits turns out to be easy once you know how. Editing of filenames in a ListView control is enabled or disabled simply by setting the appropriate bit in the window's style member. The flag of interest is LVS_EDITLABELS
. By clearing this bit, filename editing is automatically disabled for both
and left-mouse 'click-on-a-highlighted-item'.
Clearing the LVS_EDITLABELS
flag is simple:
// 'hwndListView' is a handle to the SysListView32 control. DWORD dwStyle = GetWindowStyle( hwndListView ); // get original style flags // Remove the LVS_EDITLABELS style. ::SetWindowLong( hwndListView, GWL_STYLE, dwStyle & ~LVS_EDITLABELS );
The only subtlety here is that the ListView loses the style settings whenever the user navigates to a different folder. Why? Because whenever a different folder is chosen, the current SysListView32
is destroyed and a new one is created. This can be verified using a spy tool such as and watching the window handles.
But this is not really a problem. The dialog sends a CDN_FOLDERCHANGE
notification message once the user has navigated to a different folder. By monitoring for this notification, we know when we have to adjust the window style of the (new) SysListView32
control.
The items shown in a mouse right-click context menu are unpredictable: they depend on whether specific software (such as WinZip or Norton Anti-Virus) is installed on the system, on security settings (user permissions), and the version of the operating system itself. Therefore, while it's possible in theory to disable only specific items on the context menu, in practice doing so quickly becomes a nightmare.
Easier and far more reliable is to simply disable the context menu entirely. This is accomplished by subclassing the SysListView32
control's window procedure, then monitoring for WM_CONTEXTMENU
messages and discarding them:
// 'NewListViewWndProc' is the subclassed SysListView32 window procedure. LRESULT CALLBACK NewListViewWndProc( HWND hwnd, UINT uiMsg, WPARAM wParam, LPARAM lParam ) { switch (uiMsg) { case WM_CONTEXTMENU: // trying to create a context menu? { return 0; // eat this message, prevent context menu } break;
The only subtlety here is the same as mentioned earlier: subclassing is lost whenever the user navigates to a different folder. You must re-subclass the SysListView32
control each time a CDN_FOLDERCHANGE
notification is detected.
This is an example of the SysListView32
child control working in concert with its parent SHELLDLL_DefView
. It turns out the controls support dragging either with the left mouse button or the right mouse button. We therefore have to trap both types of activity if we wish to disable file drags in the dialog.
Disabling Mouse Left-Button Drags
When a file drag is started within the SysListView32
by holding down the left mouse button, the control sends a WM_NOTIFY
message to its parent SHELLDLL_DefView
with an LVN_BEGINDRAG
notification code. It's the SHELLDLL_DefView
that actually handles the subsequent file drag operation within the ListView.
By subclassing the SHELLDLL_DefView
's window procedure, we can monitor for WM_NOTIFY
messages and discard any that have an LVN_BEGINDRAG
notification code. The SHELLDLL_DefView
never sees the notification, so a file drag operation is never initiated. Result: mouse left-button file dragging within the dialog ListView control is effectively disabled.
// 'NewShellDefWndProc' is the subclassed SHELLDLL_DefView window procedure. LRESULT CALLBACK NewShellDefWndProc( HWND hwnd, UINT uiMsg, WPARAM wParam, LPARAM lParam ) { switch (uiMsg) { case WM_NOTIFY: // notification message NMHDR* pnmhdr = reinterpret_cast< NMHDR * >( lParam ); if (pnmhdr->code == LVN_BEGINDRAG) // user trying to drag? { return 0; // eat this message, don't allow drag }
Subclassing of the SHELLDLL_DefView
is lost whenever the user navigates to a different folder because the control gets destroyed and recreated, just as the SysListView32
does. You must re-subclass the SHELLDLL_DefView
each time a CDN_FOLDERCHANGE
notification is detected.
Disabling Mouse Right-Button Drags
Under normal conditions, using the right mouse button to drag a file within the dialog results in a context menu popping up when the file is dropped. Right-dragging a file out of the dialog also gives a context menu when the file is dropped into a different window. Either way, the user has an option to Move the file to a new location. This behavior has to be disabled.
Curiously, trapping LVN_BEGINDRAG
notifications turns out to have no effect on disabling right-button drags. It appears right-drags are handled by a separate mechanism, though the underlying implementation (via the COM IDropTarget
interface, see item #5 below) is doubtless the same. In any case, defeating right-button drags is simple enough: just watch for mouse WM_RBUTTONDOWN
and WM_RBUTTONUP
messages in the SysListView32
control, and discard them. Since the dialog never sees any right-clicks, a right-button drag is never initiated.
The code to eat the mouse right-click messages ends up being just another case
in the switch
statement already described earlier for trapping the WM_CONTEXTMENU
messages:
// 'NewListViewWndProc' is the subclassed SysListView32 window procedure. LRESULT CALLBACK NewListViewWndProc( HWND hwnd, UINT uiMsg, WPARAM wParam, LPARAM lParam ) { switch (uiMsg) { case WM_CONTEXTMENU: // trying to create a context menu? { return 0; // eat this message, prevent context menu } break; case WM_RBUTTONDOWN: // detected any mouse right-click message? case WM_RBUTTONUP: { return 0; // eat it: no right-mouse dragging permitted } break;
As before, you must remember to re-subclass the SysListView32
control each time a CDN_FOLDERCHANGE
notification is detected.
The LVN_BEGINDRAG
and WM_RBUTTONDOWN/WM_RBUTTONUP
tricks described above only apply to drags originating within the dialog itself. To prevent files from being dropped onto the dialog from outside, the operating system has to be told "don't drop any files here". The operating system is involved because file drag-n-drop across processes requires interprocess communication (actually COM), which is handled by the operating system.
A window registers itself as being capable of accepting dropped files by notifying the operating system through a call to the COM IDropTarget
interface. Microsoft provides a function RevokeDragDrop()
to allow a window to un-register itself from being a drop target. Therefore, simply calling RevokeDragDrop()
on the SysListView32
window prevents the dialog from accepting dropped files.
// 'hwndListView' is a handle to the SysListView32 control. ::RevokeDragDrop( hwndListView ); // remove drag-n-drop registration
The only subtlety here is the same as mentioned earlier: drag-n-drop gets re-enabled whenever the user navigates to a different folder, because the SysListView32
control gets recreated. You must therefore call RevokeDragDrop()
each time a CDN_FOLDERCHANGE
notification is detected.
Why Intercepting Both Drag-n-Drop Handlers is Necessary
The reader may wonder: if RevokeDragDrop()
is sufficient to disable file drops onto the dialog, shouldn't it also work to disable file drags starting within the dialog? After all, either you're disabling drag-n-drop, or you're not. Drags originating within the dialog can't be so very different from drags originating outside the dialog.
The answer is, RevokeDragDrop()
used by itself does work, sort of. If you call RevokeDragDrop()
and don't implement the LVN_BEGINDRAG
and WM_RBUTTONDOWN/WM_RBUTTONUP
code described above in step #4, you'll discover that file drags originating within the dialog can't be dropped anywhere within the dialog. Seems good.
But what happens if you drag files from inside the dialog to outside of it? Now you're in trouble. You'll discover that the file can be moved out of the dialog folder and dropped into another location. This happens because the target window can ask the operating system (COM) to move the file rather than just copy it. This is no good.
Therefore RevokeDragDrop()
, while necessary, is not sufficient to completely prevent unwanted file drags. You must also trap the LVN_BEGINDRAG
and WM_RBUTTONDOWN/WM_RBUTTONUP
messages.
The toolbar that appears at the top of the dialog box is a ToolbarWindow32
control which contains a set of toolbar buttons. Each button has its own ID. As explained in the Code Project article by S h a n x, once we know the ID of a button, removing the button is accomplished by sending a TB_SETBUTTONINFO
message to the toolbar to set the button's state to "hidden". This only has to be done once, after the dialog has been initialized.
// 'hwndToolBar' is a handle to the ToolbarWindow32 control. // 'TB_BTN_NEWFOLDER' is the ID for the "New Folder" button. TBBUTTONINFO tbinfo; tbinfo.cbSize = sizeof( TBBUTTONINFO ); tbinfo.dwMask = TBIF_STATE; tbinfo.fsState = TBSTATE_HIDDEN | TBSTATE_INDETERMINATE; ::SendMessage( hwndToolBar, TB_SETBUTTONINFO, (WPARAM)TB_BTN_NEWFOLDER, // which button (LPARAM)&tbinfo ); // new button state
The only challenge is how to find the ID's of the buttons in the ToolbarWindow32
. The easiest way to do this is to subclass the ToolbarWindow32
control and monitor for WM_NOTIFY
messages sent by the toolbar button(s) you're interested in. For example, hovering the mouse over a button eventually causes tooltip help to pop up, at which time a WM_NOTIFY
for that button is sent to the ToolbarWindow32
. Since a WM_NOTIFY
contains the ID of the item it was sent from, the ID of the toolbar button can be found.
Discovering the button ID's in this way only has to be done one-time, in debug mode. Thereafter you can remove the code used to detect them and just use the ID's as hard-wired constants. This is a bit of a kluge, since there is no guarantee that the ID's will be the same across all versions of Windows. However, experiments show that the same ID's are used in Win98, Win2K, WinNT4, and WinXP, so that covers most of the Windows versions currently in use.
For convenience, the source code contains a #define FIND_TOOLBAR_BUTTON_IDS
option which you can turn on to enable detection of toolbar button ID's, if you want to experiment.
The functionality of the read-only File dialog is encapsulated in a small class called CFileDialog_ReadOnly
. The class is not rigorously object-oriented since it has to provide a keyboard hook and several window callback procedures, and by their nature these cannot be member functions. However the class is easy to use: just #include FileDialog_ReadOnly.h
, create a CFileDialog_ReadOnly
object, then call the object's DoModal()
method to actually run the dialog. MFC veterans will recognize this is identical to using MFC's CFileDialog
.
The CFileDialog_ReadOnly
class is coded almost entirely in standard (non-MFC) C++, because I wanted to make it accessible to non-MFC coders. However for parsing filenames and things I needed a string class, so I broke down and ended up using MFC's CString
. No other MFC features or functionality is used, so non-MFC programmers should have little difficulty substituting std::string
(or any other string class they prefer) for the few appearances of CString
that do occur in the code. CFileDialog_ReadOnly
uses the Win32 API to create the file common dialog.
For the sample project, I did use MFC to build a dummy dialog that acts as a launchpad for debugging and testing the CFileDialog_ReadOnly
class. But the dummy dialog should be understandable by non-MFC programmers; the only code of 'interest' is within the OnButtonClickMe()
method of the TestOpenFileDialogDlg.cpp file.
Running the sample project launches the dummy dialog, which looks like this:
Clicking 'Click Me' launches a read-only File Open dialog box showing the contents of the System directory on your machine. Note that the dialog displays only *.CPL
, *.INI
, *.EXE
, or *.DLL
files, by way of demonstrating how visible files can be limited to those having a particular extension.
I use the System directory as an example because it's a directory that's always available. This guarantees the demo will work on everybody's machine. But this is a dangerous folder to run tests on! — I suggest navigating to a less important folder before trying out
or
, until you are confident the read-only dialog works as advertised.
Creating a read-only version of the File Common Dialogs turns out to be possible, though not especially simple. To defeat the unwanted behaviors, it's necessary to install several different types of message hook at different points within the dialog. Doing this without affecting the overall functionality of the dialog requires understanding the responsibilities of each child control within the dialog window.
The techniques examined here, while focused on the File dialogs, would hopefully prove useful in customizing the behavior of other common dialogs and controls.