Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ There are no tests for Explorer, the file dialogs or the third-party apps. Those

- Explorer: menu shows; favorite navigates; with several tabs, the *active* tab navigates
- Explorer (Win11): middle-click on a tab still closes it (tab row is exempt)
- Open/Save dialog (Notepad, Office, browser): navigates; typed filename is kept
- Open/Save dialog (Notepad, Office, browser): navigates; typed filename is kept; pressing Enter afterward saves or opens
- Folder picker dialog: navigates instead of accepting the folder
- All three dialogs, starting in Documents or Downloads as well as in a plain folder
- Directory Opus dual pane: clicked side navigates
- Total Commander: left/right panel, non-ASCII path *(untested so far)*
- XYplorer: `::goto` with spaces *(untested so far)*
Expand Down
14 changes: 12 additions & 2 deletions docs/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,28 @@ The folder you were in is also recorded in [history](settings.md#history) under

## Navigating and keeping your file name

Pick a folder in the menu and the dialog moves there. If you had already typed a file name, it stays:
Pick a folder in the menu and the dialog moves there. If you had already typed a file name, it stays.

1. Folderite puts the folder path in the **File name** box and presses the dialog's **Open** or **Save** button. The dialog treats a folder path as "go there".
In a **Save** dialog, Folderite types the path into the dialog's address bar and presses **Enter** there, so the **File name** box isn't touched.

In an **Open** dialog:

1. Folderite puts the folder path in the **File name** box and presses the dialog's **Open** button. The dialog treats a folder path as "go there".
2. It waits until the dialog shows the new folder.
3. It puts your text back in the **File name** box.

The text is only restored if the box is empty or still holds the folder path. If you changed the box while the dialog was moving, Folderite leaves it alone. If you had typed nothing, the box stays empty.

An Open dialog with no file types uses the address bar, like a Save dialog.

## Folder pickers

A folder picker (the dialog that asks you to choose a folder) has no file type box, and pressing its confirm button would accept the folder instead of opening it. So Folderite types the path into the dialog's address bar and presses **Enter** there, which opens the folder without choosing it. If it can't find the address bar, a tray notification says `Couldn't open <path> in File dialog.`

## In folders such as Documents

When a dialog is in a known folder such as Documents, Downloads or Desktop, its address bar has no text box yet. Folderite presses **Ctrl+L** to open one, then puts the keyboard focus back where it was, so **Enter** still saves or selects. It only presses the keys while the dialog is in front. If the dialog isn't in front, the move fails with the same notification.

## Start folders

Normally a dialog opens wherever the program or Windows decides. Folderite can move each new dialog to a folder of your choice, as soon as it appears. There are two ways to say which folder, both on the [Dialogs page](settings.md#dialogs) in Settings:
Expand Down
7 changes: 4 additions & 3 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This page follows one middle-click from the mouse to the folder change, then cov
| Mouse hook | `src/Folderite/Hooks/MouseHook.cs` | Global middle-click hook on its own thread. Swallows the click and its release when the window is supported. |
| Window to adapter | `src/Folderite/Targets/TargetRegistry.cs` | Turns the window under the cursor into an adapter, or nothing. Applies the disabled-apps list. |
| Explorer | `src/Folderite/Targets/ExplorerAdapter.cs` | `Shell.Application` COM. The active Windows 11 tab is found through `ShellTabWindowClass`. |
| File dialogs | `src/Folderite/Targets/FileDialogAdapter.cs` | Reads the breadcrumb caption. Navigates through the file name box and OK, or through the address bar for folder pickers. |
| File dialogs | `src/Folderite/Targets/FileDialogAdapter.cs` | Reads the breadcrumb caption. Navigates through the file name box and OK in Open dialogs, or through the address bar in Save dialogs and folder pickers. |
| Directory Opus | `src/Folderite/Targets/ThirdParty/DopusAdapter.cs` | Runs `dopusrt.exe`. |
| Total Commander | `src/Folderite/Targets/ThirdParty/TotalCommanderAdapter.cs` | `WM_USER+50` queries to read, `WM_COPYDATA` with a `CD` command to navigate. |
| XYplorer | `src/Folderite/Targets/ThirdParty/XYplorerAdapter.cs` | Reads the title bar, navigates with a `::goto` script sent by `WM_COPYDATA`. |
Expand Down Expand Up @@ -73,8 +73,9 @@ Folderite reads and drives Explorer through the `Shell.Application` COM object,
`FileDialogAdapter` matches a `#32770` dialog that has a `DUIViewWndClassName` or `SHELLDLL_DefView` child.

- **Read:** the dialog's address bar is a breadcrumb toolbar whose caption reads `Address: C:\...`, with the word in the user's language. Folderite finds the `ToolbarWindow32` whose parent is `Breadcrumb Parent`, cuts the text after the first `: `, and keeps it if it's a rooted path.
- **Navigate in an Open or Save dialog:** put the path into the file name box and send OK, the same as typing a folder into the box and pressing Enter. The dialog treats a folder as "go there". The old text of the box is saved first. The dialog clears the box once it arrives, so Folderite waits (up to about a second) until the breadcrumb shows the new folder and then puts your text back, unless you've typed something else.
- **Navigate in a folder picker:** a dialog without a file-type box is a folder picker. OK there would accept the current folder, so Folderite types the path into the address bar's edit control and sends Enter to that control directly. A posted Enter would go through the dialog's own key handling and turn into OK.
- **Navigate in an Open dialog:** put the path into the file name box and send OK, the same as typing a folder into the box and pressing Enter. The dialog treats a folder as "go there". The old text of the box is saved first. The dialog clears the box once it arrives, so Folderite waits (up to about a second) until the breadcrumb shows the new folder and then puts your text back, unless you've typed something else.
- **Navigate in any other dialog:** a dialog without a file-type box among its direct children takes the address bar. That covers folder pickers, where OK would accept the current folder, and Save dialogs, whose controls are nested with no ids. Text set in a Save dialog's nested file name box doesn't count, and OK then saves under the old name in the old folder. Folderite types the path into the address bar's edit control and sends Enter to that control directly. A posted Enter would go through the dialog's own key handling and turn into OK.
- **No edit control:** in a known folder such as Documents, the address bar has no edit control until real input asks for one. Messages don't create it. With the dialog in front, Folderite sends Ctrl+L, waits for the control, and navigates. The bar turns back into path buttons a moment later and takes the focus, so Folderite then moves the focus back to where it was.

`FileNameHasFolder` reports whether the file name box already holds a path, which means the app chose where to save. The dialog start folder feature uses it to leave such dialogs alone.

Expand Down
80 changes: 80 additions & 0 deletions src/Folderite/Targets/FileDialogAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Folderite.Interop;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.WindowsAndMessaging;

namespace Folderite.Targets;

Expand All @@ -16,6 +18,7 @@ internal sealed class FileDialogAdapter : ITargetAdapter
private const int IdFileNameEdit = 0x480; // edt1 (older dialogs)
private const int IdFileTypeCombo = 0x470; // cmb1; folder pickers don't have one
private const nuint VkReturn = 0x0D;
private const uint WmNextDlgCtl = 0x0028;

public string Name => "File dialog";

Expand All @@ -40,6 +43,8 @@ public bool Matches(in TargetWindow window) =>
public bool Navigate(in TargetWindow window, string path)
{
HWND root = (HWND)window.Root;
// Save dialogs nest their controls, so they have no direct file type box either and take the address bar too.
// Keep it that way: text set in their nested file name box doesn't count, and OK then saves the old name in the old folder.
bool isFolderPicker = PInvoke.GetDlgItem(root, IdFileTypeCombo).IsNull;
HWND fileEdit = FindFileNameEdit(root);

Expand Down Expand Up @@ -109,12 +114,87 @@ private static bool NavigateViaAddressBar(HWND root, string path)
HWND band = Win32.FindDescendant(root, "Address Band Root");
if (band.IsNull) return false;
HWND edit = Win32.FindDescendant(band, "Edit");
HWND focus = HWND.Null;
if (edit.IsNull)
{
if (!WaitForFront(root, out focus)) return false;
edit = OpenAddressEdit(band);
}
if (edit.IsNull) return false;
Win32.SetControlText(edit, path);
// SendMessage, not PostMessage: a posted Enter goes through the dialog's IsDialogMessage, which turns it
// into IDOK and accepts the *old* folder. Sent directly, the address band's edit handles it and navigates.
PInvoke.SendMessage(edit, PInvoke.WM_KEYDOWN, VkReturn, 0x001C0001);
PInvoke.SendMessage(edit, PInvoke.WM_KEYUP, VkReturn, unchecked((nint)0xC01C0001));
if (!focus.IsNull) RestoreFocusAfterAddressBar(root, focus);
return true;
}

/// <summary>
/// Ctrl+L leaves the focus in the address bar, where Enter would not save or select. The bar turns back into path
/// buttons a moment after navigating and takes the focus then, so wait for that. Left alone if the user moved on.
/// </summary>
private static void RestoreFocusAfterAddressBar(HWND root, HWND focus)
{
Task.Run(async () =>
{
for (int i = 0; i < 40; i++)
{
await Task.Delay(25);
if (!PInvoke.IsWindow(root)) return;
HWND now = FocusedControl(root);
if (!now.IsNull && Win32.GetClassName(PInvoke.GetParent(now)) == "Breadcrumb Parent")
{
PInvoke.SendMessage(root, WmNextDlgCtl, (nuint)(nint)focus, 1);
return;
}
}
});
}

/// <summary>
/// Waits for the dialog to be in front, so keys can't land in another app, and for it to have its focus back, which
/// comes a moment later when the menu has just closed. <paramref name="focus"/> stays null if it doesn't come back.
/// </summary>
private static bool WaitForFront(HWND root, out HWND focus)
{
focus = HWND.Null;
for (int i = 0; i < 15 && (PInvoke.GetForegroundWindow() != root || focus.IsNull); i++)
{
if (PInvoke.GetForegroundWindow() == root) focus = FocusedControl(root);
if (focus.IsNull) Thread.Sleep(20);
}
return PInvoke.GetForegroundWindow() == root;
}

/// <summary>
/// In a known folder such as Documents, the address bar has no edit box until someone asks for one, and only real
/// input does that. Ctrl+L asks for it. The dialog has to be in front.
/// </summary>
private static HWND OpenAddressEdit(HWND band)
{
INPUT[] keys = [KeyEvent(VIRTUAL_KEY.VK_CONTROL, false), KeyEvent(VIRTUAL_KEY.VK_L, false), KeyEvent(VIRTUAL_KEY.VK_L, true), KeyEvent(VIRTUAL_KEY.VK_CONTROL, true)];
if (PInvoke.SendInput(keys, System.Runtime.InteropServices.Marshal.SizeOf<INPUT>()) != keys.Length) return HWND.Null;

HWND edit = HWND.Null;
for (int i = 0; i < 25 && edit.IsNull; i++)
{
Thread.Sleep(20);
edit = Win32.FindDescendant(band, "Edit");
}
return edit;
}

private static INPUT KeyEvent(VIRTUAL_KEY vk, bool up) => new()
{
type = INPUT_TYPE.INPUT_KEYBOARD,
Anonymous = new() { ki = new KEYBDINPUT { wVk = vk, dwFlags = up ? KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP : 0 } },
};

private static unsafe HWND FocusedControl(HWND root)
{
uint thread = PInvoke.GetWindowThreadProcessId(root, null);
var info = new GUITHREADINFO { cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf<GUITHREADINFO>() };
return thread != 0 && PInvoke.GetGUIThreadInfo(thread, ref info) ? info.hwndFocus : HWND.Null;
}
}
Loading