* Fix dark title bar for shortcuts window * Adjust editor sizes * Fetch accent button style from resources instead * Modernize the editor UI Reimplemented the XAML bridge to support Mica * Use fluent icons * Modernize the preview key visuals * Implement teaching tips for key drop-down messages * Fix spelling * Fix delete button alignment in keys editor * Remove trace log from bridge message handler * Add WinUI depends to installer script * Hide icon and caption from editor title bar * Update remap entries to look like cards * Use built-in content dialog buttons * Update add button * Fix spelling * Fix installer script for ARM64 * Fix spelling AGAIN * Update dev documentation * Prevent white flash on dark mode * Revert 3-key layout but make window wider * f: align webview versions * f: add pipeline exceptions for Microsoft DLLs that are not versioned * f: add vcruntime140_1_app.dll to the exception list * f: update webview versions
22 KiB
Keyboard Manager UI
Table of Contents:
- XAML Implementation
- UI Structure
- EditKeyboardWindow / EditShortcutsWindow
- SingleKeyRemapControl
- ShortcutControl
- KeyDropDownControl
XAML Implementation
The KBM UI was originally implemented as a XAML Island, but in order to easily support Mica, which is still an issue, the XamlBridge
was rewritten to use a FrameworkView
object which is how a traditional UWP app behaves:
- A
CoreWindow
is created by calling a function inside ofWindows.UI.dll
with an ordinal number of1500
. - A stubbed implementation of
CoreApplicationView
was created. - Then both objects are passed on to the
FrameworkView
to initialize the XAML framework. - Lastly, the
CoreWindow
is attached to the editor window and itsHWND
is used in-place ofDesktopWindowXamlSource
's.
Mica is then achieved by calling BackdropMaterial::SetApplyToRootOrPageBackground()
in both of the editor windows, or falls back to the ApplicationPageBackgroundThemeBrush
background if Mica isn't available.
The UI was also updated to use WinUI 2.8 to match the look and feel of the Fluent design language of Windows 11 and the rest of PowerToys. There has been talk about migrating the implementation to XAML files instead of code-behind and utilizing WinUI 3 going forward. More about the update can be read in here.
Link to the original documentation
UI Structure
The KBM UI consists of a Grid
with several columns. Rows are added dynamically when the add button is pressed. A vector of vector of unique pointers to SingleKeyRemapControl
/ShortcutControl
is created so that references to the UI components and their data are not lost until the window is closed. SingleKeyRemapControl
is the UI class for each row of the Remap keys table, and ShortcutControl
is the UI class for each row of the Remap shortcuts table. KeyDropDownControl
is used for handling the ComboBox operations. Each of these two classes have vectors of unique pointers to the KeyDropDownControl
objects so that references to the objects are active until the control is deleted.
When the UI windows are activated the KeyboardManagerState
object sets the UIState
variable which is used for distinguishing if the UI is up from the keyboard hook thread. The states are also updated on opening and closing the Type window.
Clicking the Type Button opens a content dialog which registers key delays using the KeyDelay
class for Enter and Esc keys and sets the UI states such that when a key event occurs the TextBlocks on the ContentDialog are updated accordingly. On accepting the dialog the selected keys are copied into the ComboBoxes from the TextBlocks, and on closing the window the key delays are unregistered and UI states are reset.
Since ComboBoxes are added dynamically, handlers have been added which update the accessible names for these controls, which get executed whenever a drop down is added or removed.
When the EditKeyboardWindow
/EditShortcutsWindow
is created, we iterate through the remappings stored in KeyboardManagerState
and add rows to the UI Grid. For both the windows we have static
buffers singleKeyRemapBuffer
and shortcutRemapBuffer
which store the corresponding key/shortcuts as per the selections in the UI if they are valid with no warnings.
EditKeyboardWindow / EditShortcutsWindow
OK and Cancel button
On pressing the OK button in EditKeyboardWindow
, first the CheckIfRemappingsAreValid
method is executed which performs basic validity checks on the current remappings in the remap buffer (static SingleKeyRemapControl::singleKeyRemapBuffer
), such as if there are no NULL columns and none of the source keys are repeated. All other validity checks are assumed to happen while the user adds the remapping. If this is found to be invalid a ContentDialog is displayed which shows that some remappings are invalid and if the user proceeds only the valid ones will be applied. If it is valid GetOrphanedKeys
is executed which checks if any keys are orphaned (i.e. the key has been remapped and no other key has been remapped to it, so there is no way to send that key code), and a dialog is shown for notifying the user with a list of orphaned keys. After this the settings are applied by adding it to the KeyboardManagerState.singleKeyReMap
member and they are saved to the JSON file. EditShortcutsWindow
differs slightly from this, as there is no orphaned keys check, and on pressing OK both the global and app-specific shortcuts are validated and updated.
The code used for updating the remapping tables in KeyboardManagerState
can be found here. For shortcut remaps, the sortedKeys
vectors are updated and re-sorted whenever an element is added to them (like this).
On pressing OK (after confirmation dialogs) or Cancel, the window is closed and UI states are reset.
Delete button
Since there is no single method to delete the elements in a row for a Grid, the logic we use involves decrementing the rowIndex for all the UI controls that appearing after the row to be deleted, and removing each of the items of the row from the Grid, followed by deleting that row definition. We also update the accessible names for all the rows since the indexing has changed. After this the corresponding row in the remap buffer is also deleted, and SingleKeyRemapControl
/ShortcutControl
objects are deleted from the vector.
Handling common modifiers in EditKeyboardWindow
In the SingleKeyRemap table for a remapping of the form Ctrl->X, where Ctrl is the common version and not L/R, we can't store it directly as Ctrl->X because when the hook receives the key event it only gets LCtrl or RCtrl specifically and not VK_CONTROL
. To simplify the backend code, when single key remappings are applied, any remapping of the form Ctrl->X is split into Ctrl(L)->X and Ctrl(R)->X (i.e. both L and R versions are remapped to the same target), and when remappings are loaded in EditKeyboardWindow, we pre-process the remap table such that if the L and R versions of a modifier are remapped to the same key/shortcut we combine them. This also results in the behavior where a user adds LCtrl->X and RCtrl->X and after closing and re-opening KBM UI it appears combined as Ctrl->X.
SingleKeyRemapControl
The left drop down column uses a single ComboBox and the Type button is linked to createDetectKeyWindow
, whereas the right column is linked to createDetectShortcutWindow
as the column can accept a key or a shortcut (required to support key to key and key to shortcut). The KeyDropDownControl
for the left column in the window uses a smaller key list, without None
.
ShortcutControl
Both the columns in are linked to createDetectShortcutWindow
, however the drop down selection handlers differ in their logic as the left column only allows shortcuts, and the drop downs do not contain the Disable
key, whereas the right column allows you to select both shortcuts and keys (to support shortcut to shortcut and shortcut to key), and it allows selection of Disable
.
For the app-specific shortcut target app text-box, we had to validate that the shortcut row is still valid when the target app is changed (for example, Ctrl+A is remapped for Chrome, and another remapping for Ctrl+A was remapped to Edge, but the target was changed to Chrome.). For this we didn't use the TextChanged handler as every time a letter is typed it would get executed. Instead we used the LostFocus
handler which gets executed whenever you focus into the box (by clicking or tabbing) and then tabbing or clicking out. In the method we perform the same shortcut buffer validation used for selections in the drop down menus and update the buffers accordingly.
KeyDropDownControl
Each ComboBox has a linked flyout, which is used to show warnings to the users whenever a user selects an invalid key from the drop down. When the warning is displayed the ComboBox is also reset to -1, i.e. no selection.
For selection handlers on the ComboBoxes we couldn't use just the SelectionChanged handler directly as it gets executed even on searching for elements in the drop down. Instead we used DropDownClosed (when a user opens the drop down and searches and selects something) and SelectionChanged when the drop down is not open (for setting selections programmatically or selection made by searching with tab focus on the drop down without opening it). This was required because if we execute the selection handlers while users are searching, it could cause false positive flyout warnings if the search causes an invalid value to be selected, and flyouts cause the drop down to close leading to bad UI experience.
Localized key names
For getting localized key names and symbols for each virtual key code, whenever the key lists are accessed, i.e. whenever the drop down is opened or when GetKeyName
is called in the Type window, the current KeyboardLayout
is retrieved to ensure that the displayed key names are always updated. Since the WM_INPUTLANGCHANGED
event was having some issues with XAML islands we weren't able to use this to update the keyboard layout. In addition to this we do not refresh the UI, so the key lists get updated only on opening/interacting with them.
Single Key ComboBox Selection Handler
On making a selection in the drop down, the selection handler validates the input with the buffer from the other column and other rows. Error messages are shown using flyouts if the selection is not considered valid and the drop down and buffer for that entry are reset to empty selection. The errors that can occur on the single key ComboBox are:
- Remap to same key (A->A)
- Same key previously remapped (A->B and A->C)
- Conflicting modifier previously remapped (Ctrl->A and Ctrl(left)->B, since Ctrl also includes Ctrl(left))
If the selection is found to be valid, the
singleKeyRemapBuffer
is updated accordingly. For handlingShortcut
and key in the remap buffer for the right column, we usestd::variant
, which allows us to store either of the two types and check which one of them is present in the buffer by using theindex
method.ValidateAndUpdateKeyBufferElement
does not reference any UI components and instead takes all the relevant data as arguments. This method has tests which covers all the cases that could arise from making selections on the UI.
Shortcut ComboBox Selection Handler
On making a selection in the drop down, the selection handler validates the input with the buffer from the other column and other rows. Error messages are shown using flyouts if the selection is not considered valid and the drop down and buffer for that entry are reset to empty selection. This differs from the Single Key ComboBox handler in the sense that after validating the current selection we may perform a set of actions such as:
- Adding a drop down (if a modifier is selected)
- Removing a drop down (if None is selected)
- Clearing terminal empty drop downs (if an action key is selected in a non-last drop down and the remaining ones are empty)
After performing the corresponding action, if any, we check if the drop down resulted in an error, in which case we do a second level of validation on all the drop downs in that list of drop downs. This is done because there can be cases where an error in one drop down results us setting it to empty, and the remaining selection is also invalid. For example, you have Ctrl+A -> Ctrl+Shift+A, and you change Shift to Ctrl. This would show a warning for having two Ctrls in the shortcut being invalid, after which setting that to empty would result in Ctrl+A->Ctrl+Empty+A, which is a case of remapping a shortcut to itself.
Once this second level of validation is done, we proceed with updating the buffer. Depending on the number of drop downs with valid values, this could be either a key or a shortcut (for the right columns). We also set the buffer value for the target app while doing this.
Unlike the Single Key handler, there is a different set of errors that can occur here which are related to making a selection that is considered as a valid shortcut. The isHybridControl
argument is used to distinguish between the differing behaviors for the two types of columns (shortcut only or shortcut/key column). The errors that can occur for this handler are:
- Shortcut must start with modifier (selecting A on the first drop for the left column is invalid)
- Shortcut can't have a repeated modifier (Ctrl+Ctrl(left)+A is not a shortcut)
- Shortcut can only have up to 2 modifiers (Ctrl+Shift+Alt is not supported as we have enforced a 3 key constraint (not a backend limitation, there is an issue requesting to remove this))
- Shortcut must contain an action key (Ctrl+A and change A to None, only for left column)
- Shortcut must have at least two keys (Ctrl+A and change Ctrl to None, only for left column)
- Disable can't be a modifier or action key (Ctrl+Disable is invalid)
- Shortcut can't have more than one action key (Ctrl+Shift+A, change Shift to B)
- Remap to same shortcut(Ctrl+A->Ctrl+A)
- Same shortcut previously remapped for same target app (Ctrl+A->B and Ctrl+A->C)
- Conflicting shortcut previously remapped for same target app (Ctrl+A->B and Ctrl(left)+A->C, since Ctrl also includes Ctrl(left))
- Illegal shortcut remaps like Win+L or Ctrl+Alt+Del (since these cannot be remapped using LL hooks)
ValidateShortcutBufferElement
does not reference any UI components and instead takes all the relevant data as arguments. This method has tests which covers all the cases that could arise from making selections on the UI.
Note: After updating the buffer we have code to handle a special case, which was required to prevent scenarios where a drop down can get deleted but the corresponding KeyDropDownControl
object isn't deleted. The code checks if the drop down is still linked to the parent and accordingly deletes the KeyDropDownControl
object from the vector.
IgnoreKeyToShortcutWarning special case: An additional check was added to ignore the Map to Same key error when an existing remapping is loaded. This was because a remapping like Ctrl->Ctrl+A has an intermediate step of Ctrl->Ctrl, which could lead to an error of invalid input, even though Ctrl+A is valid. The only way to actually add this is from the Type button or by adding them in a different order (like typing Shift+A and then changing Shift to Ctrl). Since the intermediate check could fail, this was causing the app to crash since the Xaml Island wouldn't be completely loaded at that point and the Flyout can't be displayed. This is the linked issue which describes the repro scenario.