XAML Views Reference¶
The main window shell, the page hierarchy, the dialog windows, custom controls, value converters, and how themes switch at runtime.
v4 (2026-07-12): Updated for the v4 ember restyle (#175). The HIDMaestro SDK surface, OpenXInput shim, thread-pool lifecycle, and bubble-up cascade live on HIDMaestro Deep Dive. If anything here drifts from the live source, the live source wins.
All views live in PadForge.App/Views/ (PadForge.Views namespace), except MainWindow.xaml in PadForge.App/. Shared custom controls sit alongside them in PadForge.App/Controls/ (CurveEditor, TriggerTravelArc, plus the code-only RangeSlider, EqCurveControl, and SettingResetButton, the per-setting reset button most setting rows carry) and PadForge.App/Views/Controls/ (LabeledShapeIcon, ProfilePill, TriggerEffectGraph). Styled with WPF UI 4.3 (Lepo.Wpf.Ui) for Fluent 2 design.
Contents¶
- Application Shell (MainWindow)
- DashboardPage
- PadPage
- DevicesPage
- KBMPreviewView
- MidiPreviewView
- VRPreviewView
- MousePreviewControl
- SettingsPage
- ProfilesPage
- ProfileSwitchOverlay
- AboutPage
- Dialog Windows
- Value Converters
- Resource Dictionaries and Theming
- Common XAML Patterns
- Code-Behind Patterns
Application Shell (MainWindow)¶
Files: MainWindow.xaml, MainWindow.xaml.cs
Application shell: app branding bar, NavigationView sidebar, page content area, status bar, and driver overlay.
App Branding Bar¶
A custom branding bar replaces the traditional title bar. Grid x:Name="AppBrandingBar" (48px tall) holds a hamburger Button (PaneToggleBtn) on the left and a ui:TitleBar (AppTitleBar) that carries the PadForge icon + name in its header and native minimize/maximize/close with Snap Layout support. The window sets ExtendsContentIntoTitleBar="True" and WindowBackdropType="Mica". The full-screen toggle (FullScreenBtn) in the title bar's trailing content sets WindowChrome.IsHitTestVisibleInChrome="True" so it stays clickable in the non-client area. The hamburger button carries no such attribute.
The window ground is Mica plus a SteelLayer (Grid.RowSpan="3", hit-test-invisible): a near-black steel fill with a faint ember wash top-right and a cold wash on the left. UpdateSteelLayer collapses it in Light theme. The old SyncBarBackgrounds pane-sampling scheme and the TranslateTransform.Y = -12 gap-closing transform are gone.
XAML Structure¶
Three-row Grid:
- Row 0 (auto): App branding bar (AppBrandingBar: hamburger + ui:TitleBar).
- Row 1 (star): NavigationView with page containers in its ContentOverlay.
- Row 2 (auto): Status bar Border (StatusBarBorder).
- Full-window layers (Grid.RowSpan="3"): SteelLayer (ground), DriverOverlay (ZIndex 1000), FirstRunOverlay (ZIndex 900), ShutdownOverlay / StartupOverlay (ZIndex 1001).
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Branding bar -->
<RowDefinition Height="*"/> <!-- NavigationView -->
<RowDefinition Height="Auto"/> <!-- Status bar -->
</Grid.RowDefinitions>
<Grid x:Name="SteelLayer" Grid.RowSpan="3" IsHitTestVisible="False"> <!-- ember steel ground --> </Grid>
<Grid x:Name="AppBrandingBar" Grid.Row="0" Height="48"> <!-- Hamburger Button + ui:TitleBar --> </Grid>
<ui:NavigationView x:Name="NavView" Grid.Row="1" PaneDisplayMode="Left" OpenPaneLength="244"
IsBackButtonVisible="Collapsed" IsPaneToggleVisible="False"
AlwaysShowHeader="False" CompactPaneLength="48">
<ui:NavigationView.FooterMenuItems>
<ui:NavigationViewItem x:Name="NavSettings" Tag="Settings"/>
<ui:NavigationViewItem x:Name="NavAbout" Tag="About"/>
</ui:NavigationView.FooterMenuItems>
<ui:NavigationView.ContentOverlay>
<Grid>
<views:DashboardPage x:Name="DashboardPageView" Visibility="Visible"/>
<views:PadPage x:Name="PadPageView" Visibility="Collapsed"/>
<views:DevicesPage x:Name="DevicesPageView" Visibility="Collapsed"/>
<views:SettingsPage x:Name="SettingsPageView" Visibility="Collapsed"/>
<views:ProfilesPage x:Name="ProfilesPageView" Visibility="Collapsed"/>
<views:AboutPage x:Name="AboutPageView" Visibility="Collapsed"/>
</Grid>
</ui:NavigationView.ContentOverlay>
</ui:NavigationView>
<Border x:Name="StatusBarBorder" Grid.Row="2"> <!-- Status bar (5 columns) --> </Border>
<Grid x:Name="DriverOverlay" Grid.RowSpan="3" Panel.ZIndex="1000"> <!-- ... --> </Grid>
</Grid>
Navigation Model¶
No WPF Frame-based navigation. All pages are instantiated once and visibility-swapped:
NavView_SelectionChangedpasses the selected item'sTagstring toNavigateToTag.- All page containers set to
Visibility.Collapsed. - The matching page set to
Visibility.Visible. - For controller slots (tag
"Pad{N}", 1-based, soPad1is the first slot), PadPage'sDataContextis set to the selectedPadViewModel.
This preserves control state (scroll position, selected tabs, text fields) across navigation since pages are never destroyed.
Sidebar Construction¶
NavigationView items use 48px height and 14px font size. Dashboard, Profiles, and Devices are built programmatically in BuildNavigationItems(). Settings and About are declared in XAML as FooterMenuItems (NavSettings / NavAbout) with ember-colored icons, not as BuildNavigationItems entries. Items (in order):
| Tag | Content | Icon | Source |
|---|---|---|---|
Dashboard |
Dashboard | F404 FontIcon (home) |
BuildNavigationItems() |
Profiles |
Profiles | E8F1 FontIcon (people) |
BuildNavigationItems() |
Devices |
Devices | E772 FontIcon (USB) |
BuildNavigationItems() |
Settings |
Settings | E713 FontIcon (gear) |
XAML footer (NavSettings) |
About |
About | E946 FontIcon (info) |
XAML footer (NavAbout) |
Dynamic controller cards are appended after "Devices" (index 3 onward) via RebuildControllerSection(), followed by an "Add Controller" entry while any slot remains (HasAnyControllerTypeCapacity()). Each card is a fixed-width pill (233 px) inside its NavigationViewItem and contains:
- Flame power toggle. Its heat shows the slot's state (ember live, gold waiting, outline cold) and its tooltip names it
- Global slot number in telemetry mono
- Mini type segment: Xbox / PlayStation / Nintendo / Extended / KB+M / MIDI / VR tiles in VirtualControllerGroups.InOrder, active type lit, plus a fixed-width "#N" instance token. The MIDI tile is disabled while no MIDI API starts and the VR tile without SteamVR, unless the slot already carries that type
- Delete button, revealed on card hover
Called on slot create, delete, or reorder. Uses a _rebuildingControllerSection guard to prevent re-entrancy during selection changes.
Sidebar Drag Reordering¶
Drag controller cards to reorder virtual controller slots:
OnCardDragStart.PreviewMouseLeftButtonDownrecords start position.OnNavViewDragMove.PreviewMouseMovechecks threshold, thenBeginCardDrag()creates aCardDragAdorner(ghost preview) andInsertionLineAdorner(drop indicator).UpdateDragPosition. Updates adorner positions, computes target index.EndCardDrag.PreviewMouseLeftButtonUp(OnNavViewDragEnd) finishes the drag. A drop on another card swaps the two throughInputService.SwapSlots(padIndexA, padIndexB). A drop between cards of the same type group moves the slot throughInputService.MoveSlot(sourcePadIndex, targetVisualPosition). Escape cancels.
Cross-Panel Device Drag-Drop¶
Drag devices from the Devices page to a sidebar controller card:
- DevicesPage initiates DragDrop.DoDragDrop() with a DataObject keyed "DeviceInstanceGuid" carrying the device's InstanceGuid (a Guid).
- Each sidebar card's DragOver / Drop handlers (OnSidebarCardDragOver / OnSidebarCardDrop) accept the drop and assign the device through DeviceService.AssignDeviceToSlot.
Add Controller Popup¶
Popup with one button per output type, built in ShowControllerTypePopup. Re-invoking the method while the popup is open closes it rather than opening a duplicate, and a reopen inside 300 ms of the last close is suppressed, so the same click cannot dismiss and re-raise it. The seven buttons follow VirtualControllerGroups.InOrder:
| Button | AutomationId | Icon | Per-type cap |
|---|---|---|---|
| Xbox | AddXbox360Btn |
Xbox SVG | SettingsManager.MaxXbox360Slots (16) |
| PlayStation | AddDS4Btn |
DS4 SVG | SettingsManager.MaxPlayStationSlots (16) |
| Nintendo | AddNintendoBtn |
Switch logo SVG | SettingsManager.MaxNintendoSlots (16) |
| Extended | AddRawBtn |
Joystick SVG | SettingsManager.MaxExtendedSlots (16) |
| Keyboard+Mouse | AddKeyboardMouseBtn |
E961 glyph |
SettingsManager.MaxKeyboardMouseSlots (16) |
| MIDI | AddMidiBtn |
E8D6 glyph |
SettingsManager.MaxMidiSlots (16) |
| VR | AddVrBtn |
F119 glyph |
SettingsManager.MaxVrSlots (1) |
The method counts each type from Pads[].OutputType and disables a button (icon opacity 0.35) when the global slot total reaches 16 or that type hits its own per-type cap. A type at its own cap gets a "(max N)" tooltip, e.g. Main_Nintendo_Max_Format = "Nintendo (max {0})". MIDI additionally requires a MIDI API that started, Windows MIDI Services or the legacy one (SettingsViewModel.IsMidiAvailable, the Settings card's cached answer) and VR requires SteamVR (HMaestroVRController.IsAvailable()). HasAnyControllerTypeCapacity() is a separate check: it tallies created slots from SettingsManager.SlotCreated and returns true while the total stays under 16 (MaxPads). The same seven types repeat, in the same order, in the sidebar card's type segment and on the dashboard slot cards.
Status Bar¶
Bottom Border (StatusBarBorder), five columns:
1. Status text. StatusText binding (StatusMessageText), trimmed with CharacterEllipsis. A #175 decay sweep fades it out before clearing (code-behind BeginAnimation).
2. Active-profile pill. ProfilePill (StatusProfilePill, #175 item 8). Click opens the switcher flyout. An applied auto-switch flares it.
3. Device count. ConnectedDeviceCount + localized suffix, cold telemetry mono.
4. Polling frequency. PollingFrequency formatted {0:F0} + " Hz", cold telemetry mono.
5. Engine indicator. A flame Path (the shared FlameOuterGeometry) with DataTriggers on Dashboard.EngineStateKey: Running fills ember, Idle/Stopping fills gold (WaitBrush), else outline-only. EngineStatusText beside it turns EmberHotBrush when Running.
Driver Overlay¶
Semi-transparent overlay during driver install/uninstall:
- ProgressRing spinner + text message (DriverOverlayText).
- Blocks all UI (Grid.RowSpan="3", Panel.ZIndex="1000").
- Shown and hidden by RunDriverOperationAsync(), and directly by the SteamVR install handler.
Full-Window Overlays¶
Four more full-window layers span all three rows (Grid.RowSpan="3"). SteelLayer sits under the content, and the three overlays above it stay Collapsed until shown:
| Layer | ZIndex | Shown when | Contents |
|---|---|---|---|
SteelLayer |
(bottom, hit-test off) | Dark theme always | Ember steel ground under all content |
FirstRunOverlay |
900 | FirstRunTourCompleted is false at launch, or the tour is re-run from Settings. A leftover pre-v4 PadForge.firstrun marker sets the flag and is deleted |
Welcome panel (WelcomePanel) + spotlight tour (TourCanvas: TourHighlight + TourTip) |
ShutdownOverlay |
1001 | App is closing | ProgressRing + "Closing PadForge..." text (Main_ShuttingDown) |
StartupOverlay |
1001 | Orphan-sweep task (App.OrphanSweepTask) still running at launch |
ProgressRing + "Starting PadForge…" headline (Main_StartingUp) + "Cleaning up virtual controllers left from a previous session." detail (Main_CleaningPreviousSession), auto-hidden on completion |
Composition Root (Code-Behind)¶
MainWindow.xaml.cs is the service wiring hub (~8800 lines). Constructor:
- Creates
MainViewModelas root and setsDataContext. - Sets child
DataContexton Dashboard, Devices, Settings, Profiles pages. - Creates services:
SettingsService,InputService,RecorderService,DeviceService. - Wires ViewModel events to services:
StartEngineRequested/StopEngineRequestedtoInputService.Start()/Stop().SaveRequested/ReloadRequested/ResetRequestedtoSettingsService.- Driver install/uninstall to
DriverInstallerviaRunDriverOperationAsync. TestRumbleRequested/TestLeftMotorRequested/TestRightMotorRequestedper pad.- Recording flow events per pad/mapping row.
- Profile management (New, SaveAs, Edit, Load, Delete, RevertToDefault).
- Device assignment via
DeviceService.
Timer Architecture¶
| Timer | Interval | Purpose |
|---|---|---|
DispatcherTimer |
33ms (~30Hz) | InputService._uiTimer fires UiTimer_Tick to push engine state into ViewModels |
_driverStatusTimer |
5s | RefreshHidHideStatus(), RefreshHIDMaestroStatus(), RefreshMidiServicesStatus(), and SweepStatusMessage(). Started in the constructor and stopped only in OnClosing, so the status-bar decay keeps running after the engine stops. Hosting the decay on the engine 30 Hz timer would have burned in "Engine stopped." The HIDMaestro poll reads the driver store folder, since the engine installs HIDMaestro with its first virtual controller |
CompositionTarget.Rendering |
~60fps | Used by all visualization views (3D, 2D, Schematic, MIDI, KBM, VR, MousePreview) for per-frame visual updates |
DashboardPage¶
Files: DashboardPage.xaml, DashboardPage.xaml.cs
Engine toggle, slot summary cards, and the service sections.
Section order is pinned by PadForge.Tests/PageOrderContractTests.cs. Dashboard_SectionsRunInTheDecidedOrder asserts each section title's binding appears once and after the one before it, and that the Services header sits between the slot cards and the Web Controller card. Dashboard_DriverStatusStripIsGone asserts the page carries no ED5D glyph, no Dashboard_Drivers key in the XAML, in Strings.Designer.cs, or in any of the ten resx files, and no HidHideStatusText / MidiServicesStatusText / SteamVrStatusText in the page or DashboardViewModel. It also asserts the Settings page still binds all three. Those rows live there only.
Layout Structure¶
ScrollViewer
└─ StackPanel (Margin="24,16,24,16")
├─ Page header (icon + title)
├─ "Input Engine" section header (E9F5 glyph)
├─ CardBorder: Engine status card (EngineCard, Grid, 4 columns)
│ ├─ Col 0: Engine toggle flame `Path` (FlameOuterGeometry, ember when Running, gold when Idle/Stopping, else outline)
│ ├─ Col 1: EngineStatus text
│ ├─ Col 2: PollingFrequencyText
│ └─ Col 3: Online/Total devices count
├─ "Virtual Controllers" section header (E7FC glyph)
├─ ItemsControl (SlotsItemsControl, WrapPanel over a CompositeCollection:
│ SlotSummaries plus the Add Controller tile in the same wrap flow)
│ ├─ DataTemplate: slot card Border (252px wide, 5 rows)
│ │ ├─ Row 0: Power flame btn + "Slot" + SlotNumber, Delete button
│ │ │ right-aligned (revealed on hover or keyboard focus)
│ │ ├─ Row 1: Type segment (Xbox / PlayStation / Nintendo / Extended /
│ │ │ KB+M / MIDI / VR) on a recessed track + "#N" instance label
│ │ ├─ Row 2: Device roster (per device: type glyph, name, E702 Bluetooth
│ │ │ glyph on a Bluetooth link, battery glyph and percent, marquee on
│ │ │ overflow) or the DeviceName empty-state line
│ │ ├─ Row 3: StatusText + mapped/connected counts
│ │ └─ Row 4: StageLedger chips (per-stage glyphs with hover readout)
│ └─ Add Controller tile (AddControllerCard, a ClickBorder, 252px, dashed steel outline,
│ Click from the mouse, Enter or Space → AddControllerRequested, gated on ShowAddController)
└─ ServicesSection StackPanel (one panel so the welcome tour can ring
the whole group as a single target)
├─ "Services" divider (ServicesHeader, ember tick + hairline rule)
├─ "Web Controller" section (E774 glyph)
│ └─ CardBorder: Enable toggle (EnableWebControllerCheckBox), port
│ NumberBox + reset, status flame + WebControllerStatus, HTTPS note
│ (caution border, shown on WebControllerHttpsWarning), QR image +
│ URL box + copy button (shown on HasWebControllerQr, #296), footer,
│ then a divider and the Plain HTTP Address section: Also Serve
│ Plain HTTP and This PC Only toggles with resets, a port and
│ access-code grid (NumberBox + reset, read-only code box + New
│ Code), plain status flame, and the plain URL with a copy button
│ and a QR shown on HasWebControllerPlainQr
├─ "Remote Link" section (E969 glyph, #138)
│ └─ CardBorder: Enable toggle (EnableRemoteLinkCheckBox), auto-reconnect toggle,
│ port NumberBox + reset, status flame + RemoteLinkStatus text,
│ identity-protection mode ComboBox, Paired PCs list (per peer: rename,
│ Allow Remote Assignment Changes toggle, Connect, Revoke, and Assign
│ Shared Devices while online, then Revoke All), nearby-unpaired list,
│ my-code readout, connect-by-address box, footer
├─ "Head Tracking" section (E77B glyph, #355)
│ └─ CardBorder: UDP enable toggle (HeadTrackingEnabled), FreeTrack toggle
│ (HeadTrackingFreeTrack), OpenXR toggle (HeadTrackingOpenXr), OpenXR
│ runtime ComboBox (HeadTrackingOpenXrRuntimeCombo, enabled with OpenXR),
│ one Grid of port / rotation range / translation range NumberBoxes plus
│ six per-axis range NumberBoxes (#403), each with a reset button,
│ Set Neutral button (HeadTrackingRecenterCommand), HeadTrackingStatus
│ source line, footer
├─ "Motion Server" section (E7AD glyph, DSU)
│ └─ CardBorder: Enable toggle, port NumberBox, status flame, footer
├─ "Game Mods" section (EA86 glyph, #529)
│ └─ CardBorder: description, Accept DSX Game Mods toggle
│ (EnableGameModServer), port NumberBox (GameModServerPort) with
│ ResetGameModPortCommand, Run a DSX.exe Stand-In toggle (GameModDsxStandIn,
│ tooltip), each toggle with a reset button, status flame +
│ GameModServerStatus as a polite live region, footer
├─ "Game Telemetry" section (EC4A glyph, #531)
│ └─ CardBorder: description, Listen for Game Telemetry toggle
│ (GameTelemetryCheckBox, EnableGameTelemetry) with a reset button, then a
│ Grid enabled with the switch: Game ComboBox (GameTelemetryGameCombo over
│ GameTelemetryOptions, SelectedGameTelemetryOption) with
│ ResetGameTelemetryGameCommand, Game Telemetry Port, Forward to Port and
│ Full Speed (km/h) NumberBoxes, each with its reset command (the two port
│ rows also enabled by GameTelemetryReadsUdp), then the
│ Custom Formats… button (GameTelemetryFormatsButton, outside the Grid),
│ status flame + GameTelemetryStatus as a polite live region, footer
├─ "Overlays" section (E700 glyph)
│ └─ CardBorder: Menu Overlay (EnableMenuOverlay), Shift Layer Flyout
│ (EnableShiftLayerFlyout), Profile Overlay (EnableProfileOverlay) toggles
└─ "Touchpad Overlay" section (EDA4 glyph)
└─ CardBorder: Enable toggle (EnableTouchpadOverlay), opacity slider +
NumberBox + reset, reset-position button, status flame +
TouchpadOverlayStatus
The Lightbar Mirrors and Razer Sensa HD Haptics sections are gone (#494). Their outputs are the vendor rows on the Devices page, which a virtual controller drives through the Pad page's Lighting and Force Feedback tabs like any device.
Key Bindings¶
| Binding | ViewModel | Description |
|---|---|---|
EngineStateKey |
DashboardViewModel |
Drives the engine flame Path fill: Running = ember, Idle / Stopping = gold (WaitBrush), else outline-only |
EngineStatus |
DashboardViewModel |
Status text next to engine button |
PollingFrequencyText |
DashboardViewModel |
Dashboard_PollingHz_Format ("{0:F1} Hz"), e.g. "998.0 Hz". Dashboard_PollingDash while no rate is measured |
OnlineDevices / TotalDevices |
DashboardViewModel |
Device count display |
SlotSummaries |
DashboardViewModel |
ObservableCollection<SlotSummary> for slot cards |
ShowAddController |
DashboardViewModel |
Controls Add Controller card visibility |
EnableDsuMotionServer |
DashboardViewModel |
DSU enable checkbox |
DsuMotionServerPort |
DashboardViewModel |
DSU port NumberBox |
DsuServerStatus |
DashboardViewModel |
DSU status text |
EnableGameModServer / GameModServerPort / GameModDsxStandIn |
DashboardViewModel |
Game Mods toggle, port NumberBox and stand-in toggle (#529) |
GameModServerStatus / IsGameModServerRunning |
DashboardViewModel |
Game Mods status text and flame |
EnableGameTelemetry / GameTelemetryOptions / SelectedGameTelemetryOption |
DashboardViewModel |
Game Telemetry toggle and game picker (#531) |
GameTelemetryPort / GameTelemetryForwardPort / GameTelemetryFullSpeed |
DashboardViewModel |
NumberBoxes ranged 1024-65535, 0-65535 and 10-1000, each with a reset command |
GameTelemetryStatus / IsGameTelemetryRunning |
DashboardViewModel |
Game Telemetry status text and flame |
EnableWebController |
DashboardViewModel |
Web controller enable checkbox |
WebControllerPort / WebControllerStatus |
DashboardViewModel |
Web controller port and status text |
HasWebControllerQr / WebControllerQr / WebControllerUrl |
DashboardViewModel |
QR panel visibility, the QR bitmap, and the URL shown beside it (#296) |
WebControllerHttpsWarning |
DashboardViewModel |
The HTTPS note under the status line |
EnableWebControllerPlainHttp / WebControllerPlainHttpPort / WebControllerPlainHttpLocalOnly / WebControllerAccessCode |
DashboardViewModel |
The plain HTTP address's toggles, port and access code |
WebControllerPlainStatus / IsWebControllerPlainRunning / WebControllerPlainUrl / HasWebControllerPlainQr / WebControllerPlainQr |
DashboardViewModel |
The plain address's status line and flame, its URL, and its QR |
EnableRemoteLink / RemoteLinkPort / RemoteLinkConnectHost |
DashboardViewModel |
Remote Link enable, port, and connect-by-address host (#138) |
AutoReconnect |
DashboardViewModel |
Remote Link auto-reconnect toggle |
IsRemoteLinkRunning / RemoteLinkStatus |
DashboardViewModel |
Remote Link status flame and text |
RemoteLink |
DashboardViewModel |
DashboardViewModel.RemoteLink is a second reference to the same SettingsViewModel instance, not a separate object. It carries the identity-protection modes and hint, the trusted peers, the nearby-unpaired list, and the revoke commands |
HeadTrackingEnabled / HeadTrackingFreeTrack / HeadTrackingOpenXr |
DashboardViewModel |
Head Tracking UDP, FreeTrack, and OpenXR source toggles (#355, #403) |
OpenXrRuntimes / SelectedOpenXrRuntime |
DashboardViewModel |
OpenXR runtime picker items and selection |
HeadTrackingUdpPort / HeadTrackingRotationRange / HeadTrackingTranslationRange |
DashboardViewModel |
NumberBoxes ranged 1-65535, 1-180, and 1-500, each with a reset command |
HeadTrackingRangeYaw / Pitch / Roll / X / Y / Z |
DashboardViewModel |
Per-axis range NumberBoxes, 0-180 for the rotation axes and 0-500 for the translation axes. 0 follows the family range |
HeadTrackingRecenterCommand |
DashboardViewModel |
Set Neutral button |
HeadTrackingStatus |
DashboardViewModel |
Which of the three head-tracking sources is live, or why none is |
EnableMenuOverlay / EnableShiftLayerFlyout / EnableProfileOverlay |
DashboardViewModel |
Overlays-section toggles |
EnableTouchpadOverlay / TouchpadOverlayOpacity / TouchpadOverlayStatus |
DashboardViewModel |
Touchpad overlay enable, opacity, and status text |
Slot Card DataTemplate Bindings (SlotSummary)¶
| Binding | Type | Description |
|---|---|---|
PadIndex |
int |
Used as Tag for button click routing |
SlotNumber |
int |
Global slot display number (1-based) |
IsEnabled |
bool |
Controls power toggle color |
OutputType |
VirtualControllerType |
Selects which type button is highlighted (ember fill vs tertiary text) |
TypeInstanceLabel |
string |
Per-type instance number |
MappedDevices |
ObservableCollection<PadViewModel.MappedDeviceInfo> |
Row 2 device roster (type glyph, name, TransportGlyph on a Bluetooth link, battery glyph and percent, marquee on overflow) |
DeviceName |
string |
Empty-state line, shown only when MappedDevices.Count is 0 |
StatusText |
string |
e.g. "Forging", "Cold" (enabled with nothing mapped), "Awaiting devices", "Disabled", "Idle" |
MappedDeviceCount / ConnectedDeviceCount |
int |
Mapped/connected counts |
HasMappedDevices |
bool |
MappedDeviceCount > 0. Gates the ember flame and the card's warm rim |
IsVirtualControllerConnected |
bool |
Live VC present. Drops the flame to gold when false |
IsSelected |
bool |
Card whose pad page is in focus keeps the ember glow at rest |
IsCreateFailed |
bool |
VC creation failed. Outranks the awaiting-devices tooltip |
IsInitializing |
bool |
Triggers the ember flash animation (steady ember with reduced motion) |
StageLedger |
ObservableCollection<SlotStageInfo> |
Row 4 stage chips (sticks / triggers / gyro / lighting / touchpad / audio) |
Power Toggle State Machine¶
The slot power toggle is a flame Path (FlameOuterGeometry), not a glyph. It keys off HasMappedDevices and IsVirtualControllerConnected, not a raw device count. Enabled with no mapped devices stays the cold outline (heat needs fuel). There is no HIDMaestro-install state, since the driver is embedded.
Rows are in trigger order. A later row wins when several hold.
| Condition | Flame fill | Tooltip |
|---|---|---|
IsEnabled=False |
Cold outline (TextFillColorTertiaryBrush) |
"Disabled" |
IsEnabled=True |
Cold outline | "Forging" |
IsEnabled=True + HasMappedDevices=True |
EmberBrush + glow |
"Forging" |
IsEnabled=True + IsVirtualControllerConnected=False |
WaitBrush gold when HasMappedDevices=True, else cold outline |
"Awaiting devices" |
IsEnabled=True + EngineStateKey="Stopped" |
WaitBrush gold when HasMappedDevices=True, else cold outline |
"Engine stopped" |
IsEnabled=True + IsCreateFailed=True |
(fill unchanged) | "Virtual controller failed" |
IsInitializing=True |
EmberBrush (flashing, steady with reduced motion) |
"Initializing" |
Type Switch Buttons¶
7 type buttons per slot card (Xbox, PlayStation, Nintendo, Extended, KB+M, MIDI, VR) using a custom TypeSwitchButton style, seated on a recessed SegTrackBrush segment. Transparent and borderless at rest, with a rounded subtle-fill background on hover. The active type fills with EmberSegGradient and a glow (steel SteelRaisedBrush on a card with no mapped devices). Inactive types use the tertiary text color. Unavailable types (missing prerequisite, e.g. MIDI while no MIDI API runs, VR without SteamVR) show Cursor.No and a tooltip that says why (Main_MIDI_NotRunning, Main_VR_RequiresSteamVR). Clicks are guarded in code-behind. The power button also uses the TypeSwitchButton style for visual consistency.
UI Automation¶
| AutomationId | Element | Purpose |
|---|---|---|
EnableWebControllerCheckBox |
CheckBox | Web controller enable toggle |
EnableWebControllerPlainHttpCheckBox |
CheckBox | Also Serve Plain HTTP toggle |
WebControllerPlainHttpLocalOnlyCheckBox |
CheckBox | This PC Only toggle |
WebControllerAccessCodeBox |
TextBox | The read-only access code |
NewWebAccessCodeButton |
Button | New Code |
EnableRemoteLinkCheckBox |
CheckBox | Remote Link enable toggle (#138) |
Reset_{Class}_{Property}_{n} |
SettingResetButton |
Per-setting reset buttons on the service cards and paired-PC rows |
Event Handlers (Code-Behind)¶
| Handler | Event | Action |
|---|---|---|
EngineToggle_Click |
Button.Click | Raises EngineToggleRequested |
AddControllerCard_Click |
ClickBorder.Click | Raises AddControllerRequested |
GameTelemetryFormatsButton_Click |
Button.Click | Opens CustomTelemetryFormatsDialog over GameTelemetryFormats and, when the dialog returns true, writes its Result back (#531) |
DeleteSlot_Click |
Button.Click | Raises DeleteSlotRequested(slotIndex) |
PowerToggle_Click |
Button.Click | Raises SlotEnabledToggled(slotIndex, !SettingsManager.SlotEnabled[slotIndex]), inverting the live setting rather than the summary VM |
XboxType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, Xbox). HIDMaestro is embedded so no install gate |
DS4Type_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, PlayStation) |
NintendoType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, Nintendo) |
ExtendedType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, Extended) |
KeyboardMouseType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, KeyboardMouse) |
MidiType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, Midi) |
VrType_Click |
Button.Click | Raises SlotTypeChangeRequested(slotIndex, Vr) (#49) |
SlotCard_Loaded |
Border.Loaded | Wires PreviewMouseLeftButtonDown for drag start |
OnCardMouseDown |
PreviewMouseLeftButtonDown | Records drag start position. Skips if inside a Button |
OnDragMove |
PreviewMouseMove | Begins/updates card drag with ghost adorner |
OnDragEnd |
PreviewMouseLeftButtonUp | Completes swap/insert or fires SlotCardClicked for navigation |
OnDragKeyDown |
PreviewKeyDown | Cancels drag on Escape |
OnDragCaptureLost |
LostMouseCapture | Cancels the drag when capture is taken away |
Dashboard Card Drag Reordering¶
Drag to reorder (same adorner system as sidebar):
- CardDragAdorner. Ghost preview from RenderTargetBitmap snapshot. Adds 4 physical pixels to bitmap dimensions to prevent clipping at high DPI.
- InsertionLineAdorner. Accent-colored vertical line at insertion point.
- Three zones per card: left 25% = insert before, middle 50% = swap, right 25% = insert after.
- Type-group validation: cross-type drag blocked. Same-type only.
- Sidebar rebuild deferral: while a sidebar card drag is in progress, RebuildControllerSection() queues itself (_rebuildPendingAfterFade) and EndCardDrag replays the rebuild.
- Events: SlotSwapRequested(PadIndexA, PadIndexB) and SlotMoveRequested(SourcePadIndex, TargetVisualPos).
PadPage¶
Files: PadPage.xaml, PadPage.xaml.cs, PadPage.SettingResets.cs
Per-slot configuration: two-tier tab strip, optional config bars, and 18 tab panels (Tags 0-17). Tier 1 (slot scope) holds Preview, Mappings, Macros, Menus, Bass Shakers, and Output. Tier 2 (device scope) holds the device selector and the capability tabs, most gated on source-device capability.
Layout Structure¶
Grid (3 rows)
├─ Row 0 (Auto): Two-tier tab strip (StackPanel: two gradient-bordered Borders + assign-offer banner)
│ ├─ Tier 1 (slot scope, ember underline): a WrapPanel of the scope label, identity
│ │ chip, preset chip and the slot tabs, which wrap among themselves in a WrapPanel
│ │ of their own (TabStripButton, GroupName="PadTab")
│ │ ├─ RadioButton "Preview" (Tag=0, x:Name="TabController", AutomationId="TabController")
│ │ ├─ RadioButton "Mappings" (Tag=2, AutomationId="MappingsTab")
│ │ ├─ RadioButton "Macros" (Tag=1, AutomationId="MacrosTab")
│ │ ├─ RadioButton "Menus" (Tag=15, AutomationId="MenusTab", #9)
│ │ ├─ RadioButton "Bass Shakers" (Tag=16, AutomationId="BassShakersTab",
│ │ │ Visibility bound to RumbleAudioTabVisible, #236)
│ │ └─ RadioButton "Output" (Tag=17, AutomationId="OutputTab",
│ │ Visibility bound to OutputTabVisible, #270 follow-up)
│ ├─ Tier 2 (device scope, cold underline): scope label + device ComboBox on the left,
│ │ capability tabs pushed right in a WrapPanel (TabStripButtonCold, GroupName="PadTabDevice")
│ │ ├─ ComboBox (MappedDevices, item = liveness flame Path (LivenessFlameBase) + Name + battery)
│ │ ├─ RadioButton "Sticks" (Tag=3, x:Name="TabSticks")
│ │ ├─ RadioButton "Triggers" (Tag=4, x:Name="TabTriggers")
│ │ ├─ RadioButton "Force Feedback" (Tag=5, x:Name="TabForceFeedback", gated on hasForceFeedback)
│ │ ├─ RadioButton "Wheel" (Tag=11, x:Name="TabWheel", gated on wheel VID/PID)
│ │ ├─ RadioButton "Impulse Triggers" (Tag=9, x:Name="TabImpulseTriggers", gated on hasRumbleTriggers)
│ │ ├─ RadioButton "Adaptive Triggers" (Tag=6, x:Name="TabAdaptiveTriggers", gated on hasAdaptiveTriggers)
│ │ ├─ RadioButton "Lighting" (Tag=7, x:Name="TabLighting", gated on hasLightbar || hasGuideLed
│ │ │ || hasPeripheralLighting, #494)
│ │ ├─ RadioButton "Gyro" (Tag=8, x:Name="TabGyro", gated on any motion sensor, #392)
│ │ ├─ RadioButton "Touchpad" (Tag=10, x:Name="TabTouchpad", gated on hasTouchpad)
│ │ ├─ RadioButton "Audio" (Tag=12, x:Name="TabAudio", gated on hasAudio)
│ │ ├─ RadioButton "Pointer" (Tag=13, x:Name="TabPointer", gated on hasIrPointer, #146)
│ │ └─ RadioButton "Mouse" (Tag=14, x:Name="TabMouse", gated on mouse device, #200)
│ └─ AssignOfferBanner (Border, visible on HasAssignOffer): Assign / Not Now
├─ Row 1 (Auto): Extended, MIDI or Keyboard + Mouse config bar (conditionally visible)
│ ├─ ExtendedConfigBar (Visibility=Collapsed unless OutputType==Extended)
│ ├─ MidiConfigBar (Visibility=Collapsed unless OutputType==Midi)
│ └─ KbmDeviceBar (Visibility=Collapsed unless OutputType==KeyboardMouse)
└─ Row 2 (*): ContentTabControl (a TabControl with no header, whose UI Automation peer lists the selected tab's content), one TabItem per Tag (0-17),
SelectedIndex bound to SelectedConfigTab
The HMaestroProfileBar preset chip sits inline in tier 1 rather than on its own row. The Extended, MIDI and Keyboard + Mouse bars are the only Row 1 occupants. KbmDeviceBar holds the Keyboard and Mouse pickers (KbmKeyboardDeviceCombo, KbmMouseDeviceCombo, bound to KbmConfig.AvailableKeyboardDevices / AvailableMouseDevices and to KbmKeyboardDevice / KbmMouseDevice), each with its reset button and shown while the surface mode drives its half, and the picked model's capability tags (KbmKeyboardCapabilities, KbmMouseCapabilities, ChipGhost chips).
Custom Styles (UserControl.Resources)¶
| Style Key | TargetType | Properties |
|---|---|---|
DzLabel |
TextBlock |
Width=270, vertically centered, TextFillColorSecondaryBrush. Used for slider row labels. |
DzSlider |
Slider |
Min=0, Max=100, Width=200, IsSnapToTickEnabled=True, TickFrequency=0.1. |
OffsetSlider |
Slider |
Extends DzSlider with Min=-100 (for center offset). |
DzValueEdit |
TextBox |
Width=56, right-aligned, editable percentage value. Neutral hover glow. |
DzDigitEdit |
TextBox |
Width=56, right-aligned, tooltip "Raw axis value". Raw axis value edit. |
DzPercent |
TextBlock |
"%" suffix text, TextFillColorTertiaryBrush. |
ResetButton |
ui:Button |
Content glyph E72C (undo arrow), ContentTemplate FontSize=12, tooltip "Reset". Based on EmberIconButton. Per-row reset. |
ResetAllButton |
ui:Button |
Padding=8,3, FontSize=11, left-aligned, based on EmberIconButton. Section-level reset. |
TabStripButton |
RadioButton |
Slot-tier tab. GroupName="PadTab". Checked = #1AFF6B2C ember-tint fill, EmberHotBrush SemiBold text, 2px ember underline. Hover carries EmberHoverGlow. |
TabStripButtonCold |
RadioButton |
Device-tier tab. GroupName="PadTabDevice" (own radio group). Checked = ColdTintBrush fill, ColdBrush SemiBold text, 2px cold underline. Hover carries ColdHoverGlow. |
Custom Tab Strip¶
Two RadioButton groups. The slot tier (Preview / Mappings / Macros / Menus / Bass Shakers / Output) uses TabStripButton with GroupName="PadTab". The device tier (Sticks through Mouse) uses TabStripButtonCold with GroupName="PadTabDevice". The two groups are independent so WPF never auto-unchecks across tiers. SyncTabStripSelection() keeps exactly one tab checked overall (#175 item 18: only the tier owning the active tab shows a highlight). Its slot-tier test is selected <= 2 || selected == 15 || selected == 16 || selected == 17, since Menus, Bass Shakers, and Output are appended TabItem indices rather than tier-2 tags. Each button stores its Tag (0-17) and sets vm.SelectedConfigTab = Tag on click via TabBtn_Click. ConfigTabControl_PreviewKeyDown swallows Ctrl+Tab and calls CycleConfigTab, which walks the visible RadioButtons of both groups because the header-less TabControl template breaks WPF's own Ctrl+Tab handling (discussion #140).
A TabControl with hidden header (custom ControlTemplate showing only PART_SelectedContentHost) provides content switching. SelectedIndex is bound to SelectedConfigTab.
Tab Visibility Rules¶
Sticks and Triggers are hidden by output type. The capability tabs follow the selected device alone, whatever the slot type, so a KB+M or MIDI slot with a gamepad selected shows that gamepad's tabs:
| Tab | Xbox / PlayStation / Nintendo / Extended | KB+Mouse | MIDI | Capability gate |
|---|---|---|---|---|
| Preview | Visible | Visible | Visible | always |
| Macros | Visible | Visible | Visible | always |
| Mappings | Visible | Visible | Visible | always |
| Menus | Visible | Visible | Visible | always (slot scope, #9) |
| Sticks | Visible | Visible (Mouse X/Y + Scroll) | Hidden | always within Xbox/PS/Nintendo/Extended |
| Triggers | Visible | Hidden | Hidden | hidden on an Extended or Nintendo slot whose profile declares no analog triggers (ExtendedConfig.TriggerCount == 0, the Switch Pro's digital ZL/ZR), visible otherwise |
| Force Feedback | Visible if hasForceFeedback |
Same gate | Same gate | selected device's CapType is stick-class (Gamepad / Joystick / Driving / Flight / FirstPerson), or the device is a haptic mouse, keyboard or the Razer Sensa row (PeripheralOutputs.IsHapticPeripheral, #494). Hidden for every other keyboard, mouse, touchpad and MIDI device, even on an Xbox/PS/Extended slot |
| Impulse Triggers | Visible if hasImpulseTriggers |
Same gate | Same gate | source device has impulse-trigger motors (Xbox One / One S / Elite / Elite Series 2 / Series X|S, Microsoft VID). Xbox 360 and DualSense excluded |
| Adaptive Triggers | Visible if hasAdaptiveTriggers |
Same gate | Same gate | source device is a DualSense or DualSense Edge |
| Lighting | Visible if hasLightbar \|\| hasGuideLed \|\| hasPeripheralLighting |
Same gate | Same gate | a lightbar (DS4 / DualSense family, the PS Move sphere, or a web controller drawing a DS4 / DualSense) shows the lightbar cards. A mouse, keyboard or vendor lighting row that a lighting path links, or whose record says it has lighting (UserDevice.HasPeripheralLighting), shows the mode card and the peripheral panel (#494). A Guide/HOME-button LED shows only the GuideLedCard: a first-party GIP Xbox pad (One / Elite / Series) on SDL's XInput path, whose LED command works over USB only, or the 2015 Steam Controller (#209), plus the Switch home-LED population (#226: Pro Controller, right Joy-Con, Joy-Con pair, charging grip) |
| Gyro | Visible if hasGyro |
Same gate | Same gate | source device has any motion sensor (ud.HasGyro \|\| ud.HasAccel). On an accelerometer-only device the tab shows with its five gyro-rate cards collapsed (#392) |
| Pointer | Visible if hasIrPointer |
Same gate | Same gate | source device is an IR-capable Wii Remote (#146) |
| Touchpad | Visible if hasTouchpad |
Same gate | Same gate | source device has a touchpad (DualSense family, DS4, Steam Controller) |
| Wheel | Visible if hasWheel \|\| hasGenericWheel |
Same gate | Same gate | source device is a force-feedback wheel |
| Audio | Visible if hasAudio |
Same gate | Same gate | source has a speaker (DualSense / DS4 / Wii Remote) or plays HD haptic tones (Joy-Con, Switch Pro, Steam Controller / Deck, SC 2026) (#147) |
| Mouse | Visible if source is a mouse | Same gate | Same gate | source device is a mouse (per-device mouse-gesture settings, #200) |
VR slots (#49) hide both Sticks and Triggers: the Vr lane reads none of the stick or trigger tuning keys those tabs edit.
Tag numbers: Preview 0, Macros 1, Mappings 2, Sticks 3, Triggers 4, Force Feedback 5, Adaptive Triggers 6, Lighting 7, Gyro 8, Impulse Triggers 9, Touchpad 10, Wheel 11, Audio 12, Pointer 13, Mouse 14, Menus 15, Bass Shakers 16, Output 17. Bass Shakers (4.1.0, #236, AutomationId BassShakersTab) is slot-tier and gates on SLOT TYPE, not device capability: visible via RumbleAudioTabVisible for Xbox, PlayStation, and Nintendo slots plus Extended slots with a force-feedback surface. Output (#270 follow-up, AutomationId OutputTab) is slot-tier too and carries the SOCD and Keep Controller Awake cards. OutputTabVisible is false only for MIDI and VR slots, which have no output-behavior surface. The Audio tab was speaker-only before 3.6.0. It now shows for any haptic-tone pad as well. The Lighting tab used to be lightbar-only. It now also raises for a device with only a Guide/HOME-button LED (#209, and since #226 the Switch home-LED devices: Pro Controller, right Joy-Con, Joy-Con pair, charging grip): the lightbar cards (LightbarModeCard, LightingLightbarSubtitle, LightingPlayerIdleHint) collapse and the GuideLedCard brightness control takes their place. Since #494 it also raises for a lit mouse, keyboard or vendor row: LightbarModeCard stays, the DualShock 4 / DualSense preview art (LightbarPreviewPanel) and the two lightbar hints collapse, and PeripheralLightingPanel shows (see below).
If the selected tab is hidden, the view auto-switches to Preview (index 0). SyncTabVisibility() toggles the device-tier capability tabs from the selected device's capabilities, and also drives the motor activity bars (MotorBarsGrid).
Slot Identity Chip¶
The Preview tab itself (Tag 0, x:Name="TabController", header text Pad_Tab_Preview) is text-only. The type icon lives in the standalone, non-interactive identity chip at the left of tier 1, which stays ember regardless of which tab is checked. It picks its icon via DataTriggers on OutputType:
Xbox→XboxControllerIcon(Image)PlayStation→DS4ControllerIcon(Image)Nintendo→NintendoControllerIcon(Image)Extended→ExtendedControllerIcon(Image)- anything else →
GenericControllerIcon(the Image default) Midi→EC4Fglyph (TextBlock, Image collapsed)KeyboardMouse→E961glyph (keyboard, TextBlock, Image collapsed)
The DrawingImage resource keys (XboxControllerIcon, DS4ControllerIcon) keep the v2 short names. One icon represents the whole family regardless of which specific HM profile (Xbox 360 / Xbox One / Series / Elite / Adaptive, or DS4 / DualSense / DualSense Edge) the slot ends up running. Beside the icon, one TextBlock carries OutputTypeDisplayName plus a TypeInstanceLabel Run in telemetry mono, so the type name and the "#2" token share a baseline.
Multi-Device Selector¶
Inline ComboBox bound to MappedDevices / SelectedMappedDevice. Each item shows a liveness-flame Path on the LivenessFlameBase style (fills ember with a glow when IsOnline, outline-only stroke when offline), device Name in cold, and its battery percentage when the device reports one (#167). Sits in tier 2 (device scope) on the left, beside the "Device" scope label, ahead of the right-pushed capability tabs.
UI Automation Properties¶
| AutomationId | Element | Purpose |
|---|---|---|
TabController |
RadioButton (tab 0) | Preview tab identification. x:Name kept from v2/v3, header text is now "Preview" |
MappingsTab |
RadioButton (tab 2) | Mappings tab identification |
KbmSurfacesCombo |
ComboBox | Keyboard + Mouse surfaces picker in the preset chip (#408) |
HMaestroProfileCombo |
ComboBox | HIDMaestro profile selection for Xbox / PlayStation / Nintendo slots. Extended has its own ExtendedProfileCombo (no AutomationId) |
RawStickCountBox |
TextBox | Extended slot thumbstick count override |
ExtendedTriggerCountBox |
TextBox | Extended slot trigger count override |
RawPovCountBox |
TextBox | Extended slot POV count override |
RawButtonCountBox |
TextBox | Extended slot button count override |
DeadZoneShapeCombo |
ComboBox | Deadzone shape selector (Sticks tab) |
SensitivityXCombo |
ComboBox | Sensitivity X preset (Sticks tab) |
TriggerPresetCombo |
ComboBox | Trigger sensitivity preset (Triggers tab) |
ControllerModelHost |
Grid | Preview tab visualization host |
TouchSensors |
TouchSensorStrip | A Valve slot's touch sensor capsules at the top center of the host, over the 2D and 3D views. Collapsed on every other slot. See TouchSensorStrip |
ViewModeToggle |
Button | 2D/3D preview toggle |
MacroAddButton / MacroRemoveButton |
ui:Button | Macro list add and remove |
MenuAddButton / MenuRemoveButton |
ui:Button | Menu list add and remove (#9) |
MacrosTab / MenusTab / BassShakersTab / OutputTab |
RadioButton | The remaining slot-tier tabs |
AssignOfferBanner |
Border | The assign-offer banner raised when a newly seen device matches the slot |
AssignOfferAccept / AssignOfferDismiss |
ui:Button | Its two answers |
Reset_{Class}_{Property}_{n} |
SettingResetButton |
Per-setting reset buttons. The numeric suffix keeps each id unique |
Event Handlers (Code-Behind)¶
| Handler | Trigger | Action |
|---|---|---|
PadPage_Loaded |
UserControl.Loaded | Calls ApplyViewMode, SyncTabStripSelection, SyncExtendedConfigBar, SyncMidiConfigBar, the lightbar and audio hex-box syncs, subscribes the sound and icon package registries, refreshes both package lists, syncs the Bass Shakers meter timer, and subscribes PeripheralOutputs.LinksChanged, StatusChanged and ClaimsChanged (#494). PadPage_Unloaded drops those subscriptions |
OnPeripheralOutputsChanged |
PeripheralOutputs events, on the thread that raised them |
Queues one SyncTabVisibility() on the dispatcher per burst (_peripheralSyncQueued), so a device that gains or loses an output, a path that starts or stops answering, or a claim that changes rank redraws the Force Feedback and Lighting tabs (#494) |
OnDataContextChanged |
DataContextChanged | Unsubscribes old VM, subscribes new VM PropertyChanged, resyncs all |
ViewModeToggle_Click |
Button.Click | Toggles SettingsViewModel.Use2DControllerView, calls ApplyViewMode |
TabBtn_Click |
RadioButton.Click | Sets vm.SelectedConfigTab from Tag |
Motor_MouseEnter/Leave |
ClickBorder.MouseEnter/MouseLeave | Hover opacity effect (0.7/1.0) |
LeftMotor_Click |
ClickBorder.Click (ClickMode Press) | padVm.FireTestLeftMotor() |
RightMotor_Click |
ClickBorder.Click (ClickMode Press) | padVm.FireTestRightMotor() |
MapAllToggle_Click |
Button.Click | Starts Map All, or stops it when already active (button text is MapAllButtonText) |
CalibrateCenter_Click |
Button.Click | StickConfigItem.StartCalibration() |
ProfileCombo_PreviewKeyDown |
ComboBox.PreviewKeyDown | Forwards Enter/Esc on HMaestroProfileCombo and ExtendedProfileCombo to commit / dismiss |
ExtendedCustomValue_Changed |
TextBox.LostFocus | Applies clamped Extended layout overrides (sticks/triggers/POVs/buttons) |
ExtendedCustomValue_KeyDown |
TextBox.KeyDown(Enter) | Same as LostFocus apply |
MidiConfig_Changed |
TextBox.LostFocus | Applies clamped MIDI config, rebuilds mappings if counts change |
MidiConfig_KeyDown |
TextBox.KeyDown(Enter) | Same as LostFocus apply |
MidiOutputPortBox_DropDownOpened |
ComboBox.DropDownOpened | Reads the output ports again (MidiBackendLegacy.ListOutputPortsBounded) and refills the picker |
MidiOutputPortBox_SelectionChanged |
ComboBox.SelectionChanged | Writes the pick through MidiConfig.ChooseOutputPort, empty for None, which also marks it the profile's choice |
ResetExtendedSetting_Click / ResetMidiSetting_Click |
SettingResetButton.Click | Resets the Extended or MIDI config field named by the button's Tag (PadPage.SettingResets.cs) |
StickPresetX_SelectionChanged |
ComboBox.SelectionChanged | Sets StickConfigItem.SensitivityCurveX from preset |
StickPresetY_SelectionChanged |
ComboBox.SelectionChanged | Sets StickConfigItem.SensitivityCurveY from preset |
TriggerPreset_SelectionChanged |
ComboBox.SelectionChanged | Sets TriggerConfigItem.SensitivityCurve from preset |
AppVolumeProcessDropDown_Opened |
ComboBox.DropDownOpened | action.RefreshAudioProcessesCommand.Execute() |
DeviceAxisPicker_DropDownOpened |
ComboBox.DropDownOpened | Populates ComboBox with devices assigned to current slot |
DeviceAxisIndexPicker_DropDownOpened |
ComboBox.DropDownOpened | Populates ComboBox with axis-type DeviceObjects from selected device |
OnPadVmPropertyChanged |
PadViewModel.PropertyChanged | Syncs tab strip on SelectedConfigTab, resyncs config bars on OutputType |
Preview Tab (Tab 0). Detailed¶
Grid (3 rows)
├─ Row 0 (*): Controller visualization area
│ ├─ ControllerModelView (3D, HelixToolkit)
│ ├─ ControllerModel2DView (2D overlays, Collapsed by default)
│ ├─ ControllerSchematicView (procedural Extended/HID layout, Collapsed)
│ ├─ MidiPreviewView (MIDI, Collapsed)
│ ├─ KBMPreviewView (KB+Mouse, Collapsed)
│ ├─ VRPreviewView (VR hand pair, Collapsed, #49)
│ └─ ViewModeToggle button (top-left, E8B9↔F158 icon)
├─ Row 1 (Auto): Motor activity bars (MotorBarsGrid, 400px wide, centered)
│ ├─ Col 0: Left Motor ProgressBar (MotorCapsuleTemplate) + label, clickable
│ └─ Col 1: Right Motor ProgressBar + label, clickable
└─ Row 2 (Auto): Map All controls
├─ Map All / Stop toggle Button (MapAllPillButton, Content=MapAllButtonText, Click=MapAllToggle_Click). Text flips to "Stop" when IsMapAllActive
└─ MapAllPromptText (EmberHotBrush, SemiBold, collapsed when null)
View Switching Logic (ApplyViewMode):
| Output Type | HIDMaestro Profile | User Pref | Active View | Toggle Visible |
|---|---|---|---|---|
| KeyboardMouse | . | . | KBMPreviewView | No |
| Midi | . | . | MidiPreviewView | No |
| Vr | . | . | VRPreviewView | No |
| Extended | no dedicated art (HasDedicatedArt false) |
. | ControllerSchematicView | No |
| Extended | dedicated art (the Valve pads) | 2D / 3D | ControllerModel2DView / ControllerModelView | Yes |
| Xbox | . | 2D | ControllerModel2DView | Yes |
| Xbox | . | 3D | ControllerModelView | Yes |
| PlayStation | . | 2D | ControllerModel2DView | Yes |
| PlayStation | . | 3D | ControllerModelView | Yes |
| Nintendo | . | 2D | ControllerModel2DView | Yes |
| Nintendo | . | 3D | ControllerModelView | Yes |
BindActiveModelView(): Unbinds all six views, subscribes the active view's ControllerElementRecordRequested event, then calls Bind(vm). All views fire ControllerElementRecordRequested with a PadSetting target name for click-to-record. The 2D and 3D views additionally wire AnnotationChipNavigateRequested and AnnotationsToggled.
Motor Activity Bars. Two ProgressBar capsules (MotorBarsGrid, MotorCapsuleTemplate, Minimum=0/Maximum=1) bound to LeftMotorDisplay/RightMotorDisplay. The template fills an ember gradient with a leading-edge glow that grows from zero width at 0%. Each capsule is wrapped in a clickable Grid for motor test. Hover dims to 0.7 opacity via Motor_MouseEnter/Motor_MouseLeave.
Macros Tab (Tab 1). Detailed¶
Grid (3 columns)
├─ Col 0 (250px): Macro list panel
│ ├─ DockPanel.Top: Add / Remove / Duplicate / Copy / Paste / Copy From buttons
│ └─ ListBox (Macros, item = layer-scope dot when HasLayerScope + Name)
├─ Col 1 (Auto): GridSplitter (4px, draggable)
└─ Col 2 (*): Macro editor (ScrollViewer)
└─ StackPanel (DataContext=SelectedMacro)
├─ Name TextBox (UpdateSourceTrigger=PropertyChanged)
├─ Enabled CheckBox
├─ Fire mode ComboBox, 12 items in strip order: OnPress, SinglePress,
│ OnRelease, WhileHeld, HoldForMs, ShortPress, DoublePress, TriplePress,
│ Toggle, Turbo, Always, CustomExpression
├─ Layer-scope ComboBox (LayerMask: Base plus the slot's authored layers)
├─ Mode timing rows (TriggerHoldMs, TriggerDoublePressMs, RepeatDelayMs)
├─ Trigger Combination panel (ShowsTriggerComboEditor: every mode except
│ Always and CustomExpression)
│ ├─ Trigger Source ComboBox (InputDevice/OutputController)
│ ├─ Dropdown trigger picker (#177): appends an input without recording
│ ├─ Record + Clear buttons, recording hint, RecordingLiveText while recording
│ ├─ Trigger input list (TriggerInputItems), one removable row per input.
│ │ Axis rows carry Invert / Half / Either and a dead-zone slider
│ ├─ Legacy axis threshold slider (1-100%) and direction ComboBox
│ │ (Any/Positive/Negative), visible when HasLegacyAxisTrigger
│ └─ Consume trigger buttons CheckBox
├─ Always mode description note
├─ Custom Expression formula editor (CustomExpression mode): a-z variable chips (gated on VariableCount) plus operator / comparison / logic / function chips
│ └─ Variable rows, one per letter: Source ComboBox (Assigned Devices / Virtual
│ Controller / Game Telemetry, #531), then by source the Record + Clear
│ buttons (device and virtual controller only), the output channel
│ ComboBox, or the telemetry ComboBox (TelemetryChannelOptions.Items,
│ SelectedItem TelemetryOption), each with a reset button
├─ Separator
└─ Action Sequence section
├─ Add Action / Remove buttons
├─ Actions ListBox (DisplayMemberPath="DisplayText")
└─ Action editor Border (DataContext=SelectedAction)
├─ Action Type ComboBox (grouped, `MacroTypeCatalog.View`)
└─ Type-specific panels (conditional visibility):
Grouped Action Type Picker. The picker binds ItemsSource="{Binding Source={x:Static vm:MacroTypeCatalog.View}}" with SelectedValuePath="Type" and DisplayMemberPath="Label". MacroTypeCatalog (PadForge.App/ViewModels/MacroTypeCatalog.cs) carries all 64 MacroActionType values exactly once, in display order, across eleven category shelves: Virtual Buttons, Virtual Axes & Wheel, Keyboard & Text, Mouse, Timing & Flow, Rumble, Lightbar & LEDs, Sound & Volume, Motion & Pointer, Layers & Overlays, System & Apps. MacroTypeCatalog.View is a ListCollectionView grouped on Category, and the ComboBox draws each shelf through a GroupStyle.HeaderTemplate, the arrangement the cross-device input picker uses. Item tooltips ride the ItemContainerStyle. The list is one instance for the process lifetime, refilled in place on Strings.CultureChanged so an x:Static-bound view never strands on a stale list. PadForge.Tests/MacroTypeCatalogTests.cs pins the census: every enum member appears exactly once, Choices.Count equals Enum.GetValues<MacroActionType>().Length, the categories stay eleven contiguous blocks, and the picker still binds MacroTypeCatalog.View.
Macro Trigger Section:
| Field | Binding | Visibility |
|---|---|---|
| Fire mode dropdown | TriggerMode (SelectedValue, SelectedValuePath="Tag") |
Always |
| Trigger source | TriggerSource |
ShowsTriggerComboEditor (hidden in Always and CustomExpression modes) |
| Trigger inputs | TriggerInputItems list, plus RecordingLiveText while IsRecordingTrigger |
ShowsTriggerComboEditor |
| Record button | RecordTriggerCommand (icon RecordTriggerIcon, tooltip RecordTriggerButtonText) |
ShowsTriggerComboEditor |
| Axis threshold | TriggerAxisThreshold (Slider 1-100%) |
HasLegacyAxisTrigger |
| Axis direction | TriggerAxisDirectionIndex (Any/Positive/Negative) |
HasLegacyAxisTrigger |
| Consume trigger | ConsumeTriggerButtons (CheckBox) |
ShowsTriggerComboEditor |
Action Type Editor Panels. One Visibility branch per shape, each bound to an Is*Type predicate on MacroAction. Some branches cover a family (IsAnyRumbleSetType, IsAnyAxisValueType, IsAnyMouseButtonType). The ones below are the shapes worth naming. Grep Is[A-Za-z]*Type in PadPage.xaml for the full set.
| Action Type | Visible Panel | Key Controls |
|---|---|---|
ButtonPress / ButtonRelease (plus RepeatVcButtonWhileHeld, ToggleVcButton) |
IsAnyVcButtonType |
WrapPanel of CheckBox items from ButtonOptions |
KeyPress / KeyRelease (plus RepeatKeyWhileHeld, ToggleKey) |
IsAnyKeyType |
KeyString TextBox (telemetry mono font), VirtualKeyChoices ComboBox picker, Clear button |
ButtonPress / KeyPress / Delay / MouseButtonPress / AxisHold / AxisAdd / AxisScale |
IsDurationType |
DurationMs TextBox + "ms" label |
AxisSet / AxisHold / AxisSetLatched / AxisScale (plus ToggleVcAxis, RepeatVcAxisWhileHeld, AxisAdd) |
IsAnyAxisValueType |
Axis target ComboBox (None, LStickX/Y, RStickX/Y, LT, RT, then the ten button pressure targets, shown by the action's CarriesPressure and labeled from MacroPressureLabels.Instance, #530) + AxisValuePercent TextBox. Release Axis Latches' picker (IsAxisLatchReleaseType) carries the same ten |
SystemVolume |
IsSystemVolumeType |
Axis source (Output/Input), axis selector, device picker, volume limit slider, invert toggle, OSD toggle |
AppVolume |
IsAppVolumeType |
Process ComboBox (editable, refreshes on dropdown), axis source, device picker, volume limit, invert toggle |
MouseMove / MouseScroll |
IsMouseMoveType |
Axis source (Output/Input), axis selector, device picker, sensitivity slider |
MouseButtonPress / MouseButtonRelease (plus ToggleMouseButton) |
IsAnyMouseButtonType |
Mouse button ComboBox (Left/Right/Middle/X1/X2) |
DisconnectController |
IsDisconnectControllerType |
Target-mode ComboBox (DisconnectTarget: Triggering Device / Specific Device / Slot Devices / All Devices) + specific-device picker ComboBox (DisconnectDeviceOptions, gated on IsDisconnectSpecificDevice) |
SwitchLayer |
IsSwitchLayerType |
A CardBorder with MacroAction_Type_SwitchLayer as its title, the Macro_SwitchLayer_Hint line, and a 240px ComboBox over the slot's own LayerTabs (SelectedValuePath="LayerMask", DisplayMemberPath="LayerName") writing SwitchLayerMask. Choices are Base plus every authored layer, the same name/mask pairs the layer-scope picker uses (#377) |
TriggerEffect |
IsTriggerEffectType |
A CardBorder titled MacroAction_Type_TriggerEffect (#531): Trigger ComboBox (TriggerEffectSide: Right / Left / Both), Effect ComboBox over TriggerEffectCatalog.Kinds (TriggerEffectKind), one Slider + TextBox row per parameter (TriggerEffectRows), the invalid-values line while TriggerEffectIsValid is false, Holds ComboBox (TriggerEffectHold), the Time Slider + TextBox in ms while IsTriggerEffectTimed, each with a reset button, and the MacroAction_TriggerEffect_Hint line |
TriggerEffectStop |
IsTriggerEffectStopType |
A CardBorder titled MacroAction_Type_TriggerEffectStop with the Trigger ComboBox alone (#531) |
Device Axis Picker (shared by SystemVolume, AppVolume, MouseMove):
- Device ComboBox: DropDownOpened populates from devices assigned to current slot.
- Axis index ComboBox: DropDownOpened populates with IsAxis DeviceObjects from selected device.
- Uses AxisPickerItem wrapper with InputIndex and localized DisplayName.
Mappings Tab (Tab 2). Detailed¶
Grid (4 rows, x:Name="MappingDataGrid" at Row 3)
├─ Row 0 (Auto): Toolbar
│ ├─ "Copy" Button (CopySettingsCommand)
│ ├─ "Paste" Button (PasteSettingsCommand)
│ ├─ "Copy From" Button (CopyFromCommand)
│ ├─ "Map All" Button (MapAllButtonText, Click=MapAllToggle_Click)
│ ├─ "Add Layer" Button (AddShiftLayerButton, Click=AddShiftLayer_Click)
│ ├─ MappingFilterSearchBox (ui:TextBox, MappingInputSearch, find-as-you-type)
│ ├─ MappingDeviceFilterToggle (Filter24 funnel) + Popup (PickerDeviceFilterEntries checkboxes)
│ ├─ MappingSurfaceScopeBox (All / Mouse / Keyboard, session-only, shown on a
│ │ Keyboard + Mouse slot driving both halves: KbmSurfaceScopeVisible, #408)
│ ├─ Hint text (italic, secondary brush)
│ └─ "Clear All" Button (far end, EmberDestructiveButton, Click=ClearAllMappings_Click → ConfirmDialog)
├─ Row 1 (Auto): ShiftLayerTabStrip (nested layer tabs bound to LayerTabs, hidden when only Base)
├─ Row 2 (Auto): Pipeline-status chips (CURVE / GYRO / SHIFT / INV / DZ). A click
│ scrolls to the next owning row, and SHIFT cycles the layer tabs
└─ Row 3 (*): DataGrid (HorizontalAlignment=Left, every column Width="Auto")
├─ Column: "Output" (TargetLabel text)
├─ Column: "Source" (grouped ComboBox dropdown)
├─ Column: "Value" (CurrentValueText, mono)
├─ Column: Record button (ToggleRecordCommand)
├─ Column: Clear button (ClearCommand)
├─ Column: "Options" (Invert / Half / Bidirectional, Flip Output, Do Not Inherit,
│ per-source gyro sensitivity)
└─ Column: "Axis-to-Button Deadzone" (per-mapping activation threshold, #42)
DataGrid Properties:
- AutoGenerateColumns="False", CanUserAddRows="False", CanUserDeleteRows="False", CanUserReorderColumns="False", IsReadOnly="True".
- Row style: transparent background, retemplated with a #175 "rowfire" ember underline drawn at the cells' bottom edge. A plain row with no options collapses to a 26px mono compact line and expands to the full editor on click or selection.
Source Column ComboBox:
- ItemsSource="{Binding AvailableInputsView}". Grouped ICollectionView over the row's AvailableInputs (per-device groups via ComboBox.GroupStyle). On the Motion Gyro and Motion Accelerometer rows that is the slot's list for the row's sensor (#475). The modifier pickers, in the row's detail strip and on extra-source chips, bind DataContext.ParamInputsView, the slot's full list. The Up and Down pickers beside them bind DataContext.KeyInputsView, the slot's inputs that read as a key.
- SelectedItem="{Binding SelectedInput, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}".
- DisplayMemberPath="DisplayName".
Options Column:
- Invert CheckBox. IsInverted binding.
- Half CheckBox. IsHalfAxis binding.
- Bidirectional CheckBox (Pad_Either). IsBidirectional binding.
- Flip Output CheckBox. InvertOutput binding, shown while IsInvertOutputApplicable.
- Do Not Inherit CheckBox. NoInherit binding, shown while the active layer inherits (IsActiveLayerInheriting).
- Gyro sensitivity slider (0.1-10). GyroSensitivity binding, shown for gyro sources (IsGyroSource).
Motion row panel (#475): In the row's detail strip, above the Combine row:
- A caution-colored note (
MotionRowNote, shown whileShowMotionRowNote) on any row. - On the Motion Pitch, Yaw and Roll rows (
IsMotionAxisRow): the Motion Mode ComboBox overMotionResponseOptions(shown whileCanUseMotionAngle), then Top Speed and Start Speed (ShowMotionSpeedSettings) or Lean Angle (ShowMotionAngleSettings), then Motion Deadzone. Each setting is a slider, a number box, a unit and aSettingResetButton, laid out like the Ramp panel.
Picker Filter (#322):
One search box and one device-visibility popup filter the slot's choice views, the shared list and the two Motion lists (#475), so every picker on the tab reflects them when opened. Ctrl+F focuses the box through MappingsTabRoot_PreviewKeyDown on the tab root.
MappingInputSearchis find-as-you-type and session-only, never persisted. Its setter callsApplyMappingPickerFilter().- The search also filters the grid rows as well as the dropdown contents:
RowMatchesSearchmatches a row's target label or its selected source's display name. Rows are outputs, so the device-visibility set never hides them. Only typed text does. PickerDeviceFilterEntriesdrives the popup's per-device checkboxes.HiddenPickerDeviceKeys(device guids plus"any"for the device-agnostic group) persists per slot in the settings root, not in profiles.MappingPickerFilterActiveis true while either filter narrows the list, so the funnel reads as engaged.
A view filter changes what a dropdown OFFERS, never what a row's binding holds.
Sticks Tab (Tab 3). Detailed¶
ScrollViewer
├─ ItemsControl (ItemsSource=StickConfigs, DataType=StickConfigItem)
│ └─ per-stick StackPanel:
│ ├─ Title + "Reset All" button
│ ├─ Grid (2 columns)
│ │ ├─ Col 0 (*): Slider controls
│ │ │ ├─ "Calibrate Center" button (click → StartCalibration)
│ │ │ ├─ Center Offset X (OffsetSlider, -100 to 100, + digit edit + reset)
│ │ │ ├─ Center Offset Y (OffsetSlider + digit edit + reset)
│ │ │ ├─ Deadzone Shape ComboBox (6 shapes)
│ │ │ ├─ Deadzone X (DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Deadzone Y (DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Anti-Deadzone X (DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Anti-Deadzone Y (DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Linear (DzSlider + % edit + reset)
│ │ │ ├─ Sensitivity (0.1-5x multiplier, pointer sticks only: IsPointerStick)
│ │ │ ├─ Momentum CheckBox + glide slider (mouse stick only: IsMouseStick, #291)
│ │ │ ├─ "Sensitivity Curves" header + hint text
│ │ │ ├─ Sensitivity X (preset ComboBox + reset)
│ │ │ ├─ Sensitivity Y (preset ComboBox + reset)
│ │ │ ├─ Full-width CurveEditor pair (X and Y, 560px, ChartHeight=150, IsSigned)
│ │ │ ├─ "Range" header + hint, Calibrate Boundary + Reset Boundary buttons
│ │ │ ├─ Min Range X/Left (1-100, DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Max Range X/Right (1-100, DzSlider + % edit + digit edit + reset)
│ │ │ ├─ Min Range Y/Down (1-100, DzSlider + % edit + digit edit + reset)
│ │ │ └─ Max Range Y/Up (1-100, DzSlider + % edit + digit edit + reset)
│ │ └─ Col 1 (Auto): Live preview panel (MinWidth=216)
│ │ ├─ Stick position preview (212×212 Border)
│ │ │ ├─ 200×200 Ellipse background
│ │ │ ├─ Grid lines (crosshair + quadrant dashes)
│ │ │ ├─ Deadzone overlays (shape-dependent):
│ │ │ │ ├─ Axial: yellow cross arms + red center rectangle
│ │ │ │ ├─ Radial/ScaledRadial: red ellipse
│ │ │ │ ├─ Sloped/SlopedScaled: yellow wedges (SlopedWedgeGeometryConverter)
│ │ │ │ └─ Hybrid: yellow wedges + red circle center
│ │ │ ├─ Anti-deadzone ring (thin ember `#80FF6B2C` ellipse at the
│ │ │ │ output-floor radius, collapsed while both axes sit at 0)
│ │ │ ├─ Trail + peak-hold canvas (StickTrailBehavior, StickPeakHoldBehavior)
│ │ │ ├─ Cold-blue raw dot (9px, `#FF58B6E4`, RawPosX/Y, NormToCanvasConverter)
│ │ │ └─ Ember output dot (11px, `EmberHotBrush`, LiveX/Y)
│ │ ├─ Legend row (RAW / OUT / DZ chips) + PeakReadout
│ │ ├─ RAW / OUT instrument rows (InDisplay, RawDisplay, telemetry mono)
│ │ └─ CurveEditor pair (X-axis + Y-axis, ChartSize=96 each)
│ │ ├─ CurveEditor X: CurveString=SensitivityCurveX, DeadZone/MaxRange/LiveInputX
│ │ └─ CurveEditor Y: CurveString=SensitivityCurveY, DeadZone/MaxRange/LiveInputY
│ └─ Steering section (#94): mode ComboBox (Linear / Winding / Angle X / Angle Y),
│ winding rows (IsWindingMode), angle deadzone rows (IsAngleMode)
├─ LockFeedbackCard: steering lock feedback (rumble, trigger vibration, lightbar,
│ adaptive-trigger resistance, pulse length, lightbar color and timing)
└─ FlickStickCard: Flick Stick tuning, Keyboard + Mouse slots only (#225)
Deadzone Shape Options (ComboBox index):
| Index | Shape | Deadzone Overlay |
|---|---|---|
| 0 | Scaled Radial | Red ellipse (IsRadialShape) |
| 1 | Radial | Red ellipse (IsRadialShape) |
| 2 | Axial | Yellow cross arms + red center rectangle (IsAxialShape) |
| 3 | Hybrid | Yellow wedges + red circle (IsHybridShape) |
| 4 | Sloped Scaled Axial | Yellow wedges (HasSlopedWedges) |
| 5 | Sloped Axial | Yellow wedges (HasSlopedWedges) |
Per-Slider Row Pattern: Each parameter row follows this layout:
Slider and TextBox both bind to the same property (e.g.,DeadZoneX) with Mode=TwoWay. Digit edit binds to a separate *Digit property for raw axis values. Reset buttons use per-property commands (e.g., ResetDeadZoneXCommand).
Independent Axis Range Sliders:
- MaxRangeXNeg / MaxRangeX. Left/Right boundaries for X axis (1-100%).
- MaxRangeY / MaxRangeYNeg. Down/Up boundaries for Y axis (1-100%).
Triggers Tab (Tab 4). Detailed¶
ScrollViewer
└─ ItemsControl (ItemsSource=TriggerConfigs, DataType=TriggerConfigItem)
└─ per-trigger StackPanel:
├─ Title + "Reset All" button
└─ Grid (single column) holding one StackPanel
├─ Range: RangeSlider (dual-thumb, DeadZone/MaxRange 0-100%)
│ + two TextBox edits (dz.max) + two digit edits + reset
├─ Anti-Deadzone (DzSlider 0-100% + % edit + digit edit + reset)
├─ Separator
├─ "Sensitivity Curve" header + hint text
├─ Preset ComboBox (120px, `CurvePresetChoices`, the instance accessor
│ over the static `CurvePresetNames`) + reset
├─ CurveEditor (full width, IsFullWidth=True, ChartHeight=150, IsSigned=False)
│ └─ CurveString=SensitivityCurve, DeadZone/MaxRange/LiveInput bindings
└─ Live instrument panel (inset Border, Grid)
├─ RAW bar (ProgressBar 0-1, RawNorm, InstrumentBarRaw) + RawDisplay text
├─ OUT bar (ProgressBar 0-1, LiveValue, InstrumentBarOut) + OutDisplay text
└─ TriggerTravelArc (RawValue=RawNorm, OutValue=LiveValue, spans both rows)
RangeSlider. Dual-thumb control for deadzone floor and max range ceiling:
- LowerValue="{Binding DeadZone}". Deadzone threshold.
- UpperValue="{Binding MaxRange}". Max range ceiling.
- The range between thumbs represents the active trigger zone.
Force Feedback Tab (Tab 5). Detailed¶
ScrollViewer
└─ StackPanel
├─ "Force Feedback" page header (E877) + subtitle
├─ PeripheralHapticsRoute (ember TextBlock, collapsed unless the selected device is a haptic
│ mouse, keyboard or the Razer Sensa row, #494): where its rumble goes and whether that
│ path answers, from PeripheralRouteText.Haptics
├─ FalconCard (collapsed unless the selected device is a Novint Falcon, #521): header +
│ Reset All (ResetFalconAllCommand), Feel ComboBox (FalconFeel), the spring
│ (FalconSpring, Joystick only), Kick Button (FalconKickButton), kick and shake
│ strength (FalconKick / FalconShake), Brace and Preload buttons (FalconBraceButton /
│ FalconPreloadButton, Aim only), gravity compensation (FalconGravity) and the force
│ limit in newtons (FalconForceCap), each with its own reset
├─ "Force Feedback / Rumble / Haptics" card header + "Reset All" button
├─ Overall Gain slider (0-100%, ForceOverallGain)
├─ Left Motor Strength slider (0-100%, LeftMotorStrength)
├─ Right Motor Strength slider (0-100%, RightMotorStrength)
├─ "Fold Trigger Rumble into Main Motors" CheckBox (FfbTriggerFoldChk, TriggerRumbleFold)
├─ FfbSwapMotorsRow: "Swap Left and Right Motors" CheckBox (SwapMotors), collapsed for a
│ haptic mouse, keyboard or the Razer Sensa row
├─ "Test Rumble" Button (TestRumbleCommand)
├─ "Motor Activity" header
├─ Left Motor bars (ProgressBar 0-1): RawLeftMotorDisplay (the game's request,
│ with haptics played as audio on a DualSense persona, #522)
│ over DeviceLeftMotorDisplay (the selected device's output after its settings)
├─ Right Motor bars: RawRightMotorDisplay over DeviceRightMotorDisplay
├─ "Steering Angle Rumble" card (SteeringAngleRumbleEnabled, axis LX / LY / RX / RY,
│ motor Split by Direction / Left Motor / Right Motor (#510), strength 0-100,
│ deadzone 0-25, each with a reset, plus Reset All)
├─ "Constant Force" card (F0AD icon + "Reset All" ResetConstantForceCommand + description)
│ ├─ "Apply Constant Force" CheckBox (ConstantForceEnabled)
│ ├─ Drag pad (ConstantForcePadBorder, mouse handlers, SignedNormToCanvasConverter)
│ └─ X / Y sliders + F2 edits + per-axis reset (ConstantForceX / ConstantForceY)
├─ Audio Bass Rumble section
│ ├─ "Audio Rumble" header (E767 icon) + "Reset All" button + description
│ ├─ Enable CheckBox (AudioRumbleEnabled)
│ ├─ Sensitivity slider (1-20, AudioRumbleSensitivity, format F1)
│ ├─ Bass Cutoff slider (20-200 Hz, AudioRumbleCutoffHz, format F0)
│ ├─ Left Motor slider (0-100%, AudioRumbleLeftMotor)
│ ├─ Right Motor slider (0-100%, AudioRumbleRightMotor)
│ └─ Level meter (ProgressBar 0-1, AudioRumbleLevelMeter)
└─ "Trigger Routing" card (E72A icon + "Reset All" ResetTriggerRouteCardCommand + description)
├─ Left: source ComboBox, mode ComboBox, scale slider, activator display +
│ record button (LeftTriggerRouteActivatorRecordCommand, EmberIconButtonHot)
│ and activator-mode ComboBox, each with its own reset
└─ Right: the same five rows on the RightTriggerRoute* bindings
All Audio Rumble controls bind IsEnabled="{Binding AudioRumbleEnabled}". Grayed out when off.
PeripheralHapticsRoute takes its sentence from the Pad_ForceFeedback_Route* strings: the HID++ unit the rumble plays on, haptic feedback turned off for it in Logi Options+, a device that has not answered yet, the iFeel mouse the rumble plays on as a buzz, the SteelSeries GG tactile handler, the virtual controller whose Rival sets every tactile Rival's rumble, the Razer Sensa path, or the software that is not answering yet. A Razer Sensa row on the ARM64 build reads Common_NotAvailableOnArm64, since that build cannot load the Interhaptics engine. Every other line is empty while the engine is stopped (PeripheralOutputs.Running) and on a slot that is not live (PeripheralOutputs.SlotLive). The ARM64 line shows either way.
Lighting Tab (Tab 7). Peripheral Rows (#494)¶
A mouse, keyboard or vendor lighting row assigned to the slot gets the Lighting tab a DualSense gets, with the same modes. SyncTabVisibility() sets hasPeripheralLighting when the selected device has no lightbar and a lighting path links it (PeripheralOutputs.HasLighting) or its record says it has one (UserDevice.HasPeripheralLighting), so the tab stays up while the device sleeps.
Three ember lines name a DSX game mod while it holds something on the slot (#529), each collapsed when empty: GameModTriggersLine under the Adaptive Triggers tab's subtitle, GameModImpulseLine under the Impulse Triggers tab's subtitle, and GameModLightsLine in the Lighting tree below.
StackPanel
├─ "Lighting" page header (E781)
├─ LightingLightbarSubtitle + LightingPlayerIdleHint (lightbar devices only)
├─ GameModLightsLine (#529, ember, collapsed when empty)
├─ PeripheralLightingPanel (collapsed unless hasPeripheralLighting)
│ ├─ Pad_Lighting_PeripheralSubtitle
│ ├─ PeripheralLightingControlRow (collapsed for a vendor row or a unit row, which lighting is all it carries)
│ │ ├─ CheckBox "Control This Device’s Lighting" (DeviceConfig.PeripheralLightingEnabled,
│ │ │ TwoWay, ToolTip Pad_Lighting_ControlDevice_Tooltip)
│ │ └─ SettingResetButton (DeviceConfig.ResetPeripheralLightingEnabledCommand,
│ │ AutomationId Reset_DeviceSlotConfig_PeripheralLightingEnabled)
│ ├─ PeripheralClaimLevelRow (collapsed unless the device offers a Lighting Hold)
│ │ ├─ TextBlock "Lighting Hold" (Pad_Lighting_ClaimLevel)
│ │ ├─ ComboBox PeripheralClaimLevelPicker (DeviceConfig.PeripheralClaimLevel, TwoWay through
│ │ │ EnumIndexConverter, AutomationId PeripheralClaimLevelCombo, IsTextSearchEnabled off):
│ │ │ "Colors Only", "Colors and Lighting Power",
│ │ │ "Colors, Lighting Power, and On-Board Memory Mode Off" (PeripheralClaimLevelHostModeItem)
│ │ └─ SettingResetButton (DeviceConfig.ResetPeripheralClaimLevelCommand,
│ │ AutomationId Reset_DeviceSlotConfig_PeripheralClaimLevel)
│ └─ PeripheralLightingRoute (ember TextBlock, collapsed when empty)
├─ LightbarModeCard (the lightbar's mode picker and cards. Its LightbarPreviewPanel,
│ the DualShock 4 / DualSense art, collapses for these rows)
└─ IndicatorLedsCard, GuideLedCard (collapsed for these rows)
The Lighting Hold row shows for a mouse or keyboard that offers a hold above colors alone (PadPage.OfferedLightingHold: the row's DeviceLinks.MaxClaimLevel while a path of its own lights it, else what its record kept). PadPage.ShowLightingHold collapses and disables the last option for a device that does not offer the second hold. The option stays in the list, so a choice of it saved earlier still shows as the selection, and a closed ComboBox cannot step onto it with an arrow key, since it steps only to an enabled item. The picker then takes the tooltip Pad_Lighting_ClaimLevel_TooltipNoHostMode, which stops before the sentences about the last option, and the two options left report a set size of two to a screen reader. Typing a letter selects nothing in this picker, because it would select the last option where that one is out of the list.
PeripheralLightingRoute is PeripheralRouteText.Lighting(device, PadIndex, lightsHere, claimLevel) (PeripheralRouteText.cs line 155), where lightsHere is true for a vendor row or a unit row and otherwise follows the switch, in both cases only on a slot that is live (PeripheralOutputs.SlotLive), and claimLevel is the Lighting Hold this controller's claim carries. The line is empty while the engine is stopped (PeripheralOutputs.Running). Its sentence comes from the Pad_Lighting_Route* strings:
| Key | When |
|---|---|
RouteDirect / RouteDirectPower / RouteDirectHostMode |
This controller lights a device on its own, through HID++, a Razer or SteelSeries control collection, or HID LampArray, with the device named in the line. The second and third forms add what a Lighting Hold above Colors Only holds: the device does not dim its lights by itself, or its on-board memory mode is off, while it is lit |
RouteThrough / RouteThroughOrDirect |
This controller lights the device through the vendor's software, named in the line. The second form adds that PadForge lights the device on its own while that software is not running |
RouteWindowsLighting / RouteWindowsLightingBuiltIn |
Windows Dynamic Lighting lights the device, so PadForge leaves it alone. The line names the switch in Windows Settings, and the second form, for a laptop's own keyboard, what turning it off costs on some laptops |
RouteAsleep |
A device the record says has lighting has not answered yet |
RouteWaiting |
The vendor's software is not answering yet |
RouteCannotLight |
The Logitech LED engine on this PC cannot paint this device type, or will not load while its program runs (in the ARM64 build an engine that will not load reads Common_NotAvailableOnArm64). While the engine will not load, the LIGHTSYNC row reads it on every virtual controller it is on, one that is gone included, ahead of RouteOtherControllerOnly |
RouteShared |
Another device of this kind, on the virtual controller the line names, sets the shared path's color |
RouteSharedVendorRow |
A vendor row, on the virtual controller the line names, sets the shared path's color |
RouteOtherController / RouteOtherControllerOnly |
The same device on another virtual controller sets its color, or, for a device lit on its own, another row of it there does. The Only form shows while this controller's switch is off or its slot is not live. A vendor row on a slot that is not live reads the Only form with the number of the controller that lights it elsewhere, and nothing while its software is not answering |
RouteOtherRow / RouteOtherRowOnly |
Another row of the same directly lit device on this controller holds the unit |
RouteVendorRow / RouteVendorRowWaiting |
A vendor row's own line on a live slot: what its software lights that no assigned device claims, with examples (Pad_Lighting_VendorRowExamples_*), or that the software is not answering yet |
With the switch off, or on a slot that is not live, the line names only who else lights the device, never a route of its own. SyncTabVisibility() runs again on PeripheralOutputs changes (OnPeripheralOutputsChanged), when the DeviceConfig anchor swaps to another device's config, and when PeripheralLightingEnabled changes, so the line follows the switch even for a device that is offline.
Gyro Tab (Tab 8). Detailed¶
The tab opens on the E7AD page header (Pad_Gyro_Header + Pad_Gyro_Subtitle), then ten CardBorder cards in this order:
| Order | Card | x:Name | Glyph | Shown when |
|---|---|---|---|---|
| 1 | Grip (#392) | (unnamed) | E815 |
always |
| 2 | Pitch and Roll Simulation (#474) | GyroSimulationCard |
E7AD |
DualShock 3 with gyro and accelerometer |
| 3 | Motion Passthrough | GyroPassthroughCard |
E72A |
gyro rate |
| 4 | Motion Steering (#94) | (unnamed) | inline Path |
always |
| 5 | Tilt | (unnamed) | E99A |
always |
| 6 | Calibration | GyroCalibrationCard |
F272 |
gyro rate |
| 7 | Sensitivity | GyroSensitivityCard |
E9E9 |
gyro rate |
| 8 | Compass Yaw (#271) | CompassYawCard |
E707 |
Switch 2 magnetometer |
| 9 | Response | GyroResponseCard |
F1CB |
gyro rate |
| 10 | Engage (#120) | GyroEngageCard |
E7E8 |
gyro rate |
The tab raises for any motion sensor, so an accelerometer-only remote reaches Grip, Tilt, and Motion Steering. SyncTabVisibility() keeps two flags apart for that: hasGyro is ud.HasGyro || ud.HasAccel and drives TabGyro.Visibility, while hasGyroRate is ud.HasGyro alone and collapses the five rate cards named above. Before #392 the tab itself was gyro-gated, so an accelerometer-only Wii Remote never saw Tilt.
Grip card. One row: a MotionGripOptions ComboBox on MotionGrip (SelectedValuePath="Value", DisplayMemberPath="Display", 260px) plus ResetMotionGripCommand. The four holds are Pointing (the default), Sideways, WiiWheel, and Upright, stored as those canonical English identifiers. The grip rotates gyro, accelerometer, and gravity into the game's frame, and the D-pad follows it.
Engage card (#120). Below the Easy-Aim stick threshold, two ComboBoxes gate gyro engagement per stick and per direction. Both use SelectedValuePath="Tag" and reset buttons.
| Control | Binding | Items (Tag) |
|---|---|---|
| Engage Stick | GyroEngageStickSide |
Right / Left / Either |
| Engage Direction | GyroEngageStickDirection |
Full / X / Y / XNeg / XPos / YNeg / YPos |
Reset buttons run ResetGyroEngageStickSideCommand (to Right) and ResetGyroEngageStickDirectionCommand (to Full).
Mappings Tab. Per-Source Motion Sensitivity¶
Two per-source sensitivity rows render inside the mapping source editor, each gated on the selected source:
- IR Pointer X/Y (#146): visible on
IsIrPointerSource. Slider + TextBox bound toIrPointerSensitivity, reset viaResetIrPointerSensitivityCommand. - Mouse Motion X/Y (#154): visible on
IsMouseMotionSource. SameIrPointerSensitivitybinding with its own tooltip string.
Pointer Tab (Tab 13). IR Camera Tuning (#146)¶
Wii Remote IR camera tuning. TabPointer (Tag 13) is Visibility="Collapsed" by default and shown by SyncTabVisibility() only when the selected mapped device is an IR-capable Wii Remote (hasIrPointer). Two cards: Pointer Mode (PointerMode: Mouse, FPS Mouse, 4:3 Border, or 16:9 Border, plus PointerFpsSpeed) and Pointer Tuning (sensor-bar position IrSensorBarPos as Centered / Above / Below, IrSensorBarCompPercent compensation, IrSmoothingPercent smoothing), each row with a reset. The tab's subtitle (Pad_Pointer_Subtitle) sends the player to the Devices page to calibrate the remote as a light gun and says that a calibrated remote ignores the sensor-bar position and offset (#485).
Menus Tab (Tab 15). Detailed¶
Radial / touch menu editor (#9). Slot tier like Macros: menus live on the slot's MappingSet. Tag 15 is the appended TabItem's index.
Grid (3 columns)
├─ Col 0 (250px): Menu list panel
│ ├─ WrapPanel.Top: Add / Remove / Duplicate buttons
│ │ (AutomationIds "MenuAddButton" / "MenuRemoveButton")
│ └─ ListBox (Menus, SelectedItem=SelectedMenu, DisplayMemberPath="Name",
│ EmberSelectListItem container style)
├─ Col 1 (Auto): GridSplitter (4px, draggable)
└─ Col 2 (*): Empty state OR menu editor
├─ Empty state (visible while !HasSelectedMenu): outline flame Path
│ + Menu_EmptyHint cold guidance text, same shape as Macros
└─ ScrollViewer (DataContext=SelectedMenu, gated on HasSelectedMenu)
└─ StackPanel
├─ Menu Name TextBox (UpdateSourceTrigger=PropertyChanged) + Enabled CheckBox
├─ Style ComboBox (KindOptions → KindIndex: Radial Ring / Touch Grid)
├─ Show on Web Controller CheckBox (ShowOnWebController, visible on
│ ShowWebControllerRow: a Touch Grid, or the flag set, #471)
│ + SettingResetButton
├─ Layer ComboBox (LayerChoices → LayerMask) + Reset (ResetLayerCommand)
├─ Layer-hold CheckBox (LayerHoldsOpen, visible on ShowLayerHold, #413)
├─ Host Input ComboBox (HostOptions → SelectedHost, label HostInputLabel)
│ + Record button (MenuHostRecordCommand from the TabItem DataContext,
│ glyph = HostRecordIcon) + Reset (ResetHostCommand)
├─ Host Input caption (HostInputCaption, always shown: the Steer With
│ caption while the layer hold keeps the menu open, else Menu_HostInput_Caption)
├─ Custom X / Custom Y steer-axis rows (visible on IsCustomHost,
│ ResetCustomXCommand / ResetCustomYCommand)
├─ Click Input row (visible on IsCustomHost, ResetClickCommand)
├─ Pad Half ComboBox (HostHalfOptions → HostHalfIndex,
│ visible on HostIsTouchpad)
├─ Fire Mode ComboBox (FireOptions → FireTypeIndex) + Reset
├─ Cells NumberBox (1-20 → CellCount)
│ + Center Cell CheckBox (HasCenter, radial only) + Reset
├─ Engage Deadzone Slider + NumberBox (1-95 → EngageDeadzonePercent) + Reset
├─ Menu Overlay geometry card (CardBorder, header + ResetGeometryCommand)
│ ├─ Show Labels CheckBox (ShowLabels)
│ ├─ Screen Position NumberBox pair (0-100 → PosXPercent / PosYPercent)
│ ├─ Size NumberBox (10-400 → ScalePercent) + "%"
│ └─ Opacity NumberBox (5-100 → OpacityPercent) + "%"
├─ Cell Bindings ItemsControl (Cells)
│ └─ per cell: Header + icon indicator (IconImage, the emoji itself
│ on IsGlyphIcon, or the E8B9 picture glyph on ShowIconGlyph) + icon size slider
│ (IconScalePercent 25-200, visible on HasIcon) + Label TextBox (LostFocus)
│ + Binding ComboBox (BindingKindOptions → BindingKind)
│ + key picker (KeyOptions → SelectedKeyVk, visible on ShowKeyPicker)
│ OR button picker (ButtonOptions → SelectedButtonFlag,
│ visible on ShowButtonPicker)
│ OR macro picker (MacroOptions → SelectedMacroName,
│ visible on ShowMacroPicker, #390)
│ + Choose Icon button (EB9F, MenuCellChooseIcon_Click, #390: opens
│ IconPicker through IconPickerHost.Open, #471)
│ + Reset (ResetCellCommand)
└─ Icon Packages card (CardBorder, EB9F glyph, #390)
├─ Pad_Menus_IconPackages_Header + description
├─ Add (IconPackageAdd_Click) / Create (IconPackageCreate_Click) /
│ Remove (IconPackageRemove_Click) buttons
├─ IconPackagesEmptyText (Pad_Menus_IconPackages_Empty)
└─ IconPackagesList ListBox (MaxHeight 170, EmberSelectListItem,
per row: EB9F glyph + Name + Path)
Every setting row carries the canonical Reset button, and the host row carries Record too (the Aim Engage cluster shape). The record button binds MenuHostRecordCommand on the PadViewModel through the TabItem's DataContext because the editor panel's own DataContext is the selected MenuEditorItem.
The four cell pickers each use ComboBoxWidthBehavior.SizeToItems with a per-column WidthGroup (MenuCellKindCombos, MenuCellKeyCombos, MenuCellButtonCombos, MenuCellMacroCombos), so rows stay in column across selections and long locales do not clip.
Icon Packages block (#390). Takes the Sound Packages card's shape. A pack is one zip file with the .pficons extension holding image entries plus an optional manifest.json display name (PadForge.App/Common/IconPackageManager.cs). PadForge never extracts a pack: importing one registers its path, and cells reference entries as pficon://PackName/entry.png beside loose image paths and Steam binding-icon names. The list and its empty-state text are filled from code-behind (RefreshIconPackages sets IconPackagesList.ItemsSource from IconPackageManager.Packages), and IconPackageManager.RegistryChanged re-runs it.
HIDMaestro Profile Bar¶
HMaestroProfileBar is a ChipGhost preset chip inline in tier 1, shown for Xbox, PlayStation, Nintendo, and KeyboardMouse slots. SyncExtendedConfigBar() sets its visibility to (HasHMaestroProfileBar && !isExtended) || isKbm. HasHMaestroProfileBar is true for Xbox, PlayStation, Nintendo, and Extended, but Extended slots hide this compact chip and use the separate ExtendedConfigBar (with its own ExtendedProfileCombo) instead. The chip carries two pickers and shows one of them:
| Control | AutomationId | Binding |
|---|---|---|
| Profile ComboBox | HMaestroProfileCombo |
ProfileId, items from AvailableProfiles (HMaestro profile catalog). Hidden on a Keyboard + Mouse slot |
| Surfaces ComboBox | KbmSurfacesCombo |
KbmSurfaces, items from KbmConfig.AvailableKbmSurfaces (#408). Shown only on a Keyboard + Mouse slot |
The profile drives identity (VID/PID/product string) and layout (axes/buttons/POVs/touchpad/rumble) for the HM virtual. Each picker has a SettingResetButton beside it.
Extended Config Bar¶
ExtendedConfigBar is shown when OutputType == Extended. Stacked rows:
| Row | Controls | Notes |
|---|---|---|
| 1 | ExtendedProfileCombo + ExtendedImportBtn |
Profile picker (same AvailableProfiles source) plus Import-From-Device |
| 2 | ExtendedCustomizeChk + ExtendedResetDefaultsBtn |
Master toggle for the rows below. Reset reverts to catalog defaults |
| 3 | ExtendedProductStringBox, ExtendedOemOverrideChk, ExtendedVidBox, ExtendedPidBox |
Identity overrides |
| 4 | RawStickCountBox, ExtendedTriggerCountBox, RawPovCountBox, RawButtonCountBox, ExtendedForceFeedbackChk |
Layout overrides. The count boxes kept their v2 Raw* names except triggers |
Override rows 3 and 4 bind IsEnabled to ExtendedCustomizeChk.IsChecked, so they stay disabled until Customize is on, and toggling Customize off restores the catalog profile as-is. Each field has a reset button (ResetExtendedSetting_Click, PadPage.SettingResets.cs). _syncingExtendedConfig guard prevents recursive updates inside SyncExtendedConfigBar().
MIDI Config Bar¶
Visible when OutputType == Midi. Centered horizontal StackPanel:
| Control | Binding | Range |
|---|---|---|
| Channel TextBox | MidiConfig.Channel |
1-16 |
| CC Count TextBox | MidiConfig.CcCount |
0 to 128 − StartCc (clamped against StartCc) |
| Start CC TextBox | MidiConfig.StartCc |
0-127 |
| Note Count TextBox | MidiConfig.NoteCount |
0 to 128 − StartNote (clamped against StartNote) |
| Start Note TextBox | MidiConfig.StartNote |
0-127 |
| Velocity TextBox | MidiConfig.Velocity |
1-127 |
Output Port ComboBox (MidiOutputPortBox) |
MidiConfig.OutputPort, set in code |
None, the output ports, then the saved port when it is not connected |
All fields have tooltips. _syncingMidiConfig guard prevents recursive updates. When CC/Note counts or start numbers change, vm.RebuildMappings() regenerates mapping rows. A running slot's controller takes every change on its next poll, on the same port (MidiVirtualController.ApplyLayout).
The Output Port group shows only while PadViewModel.IsLegacyMidi is true, since the Windows MIDI Services APIs create the slot's own port. SyncMidiOutputPortBox fills it: None first, then every output port by the name it is saved under, then the saved port when it is not connected now, so the pick stays visible. A second port with the same name is listed as "Name (2)". The port list is read only when the picker opens, on a worker that gives up after 2 s, because WinMM on the new MIDI stack asks the MIDI service, which can hang. _syncingMidiPort keeps the refill from writing back a pick. Its reset button picks None.
Copy From Dialog¶
Opens CopyFromDialog. Picking a source slot copies the whole mapping table plus every assigned device's tuning (deadzones, sensitivity, FFB, impulse triggers, adaptive triggers, lighting, gyro, TouchpadSettings) into the target slot. Each source device matches a target device by InstanceGuid first, then ProductGuid as a fallback for the same controller model on a different physical unit. A target device with no source-side match keeps its tuning. The copied MappingSet also carries the slot's menus, Bass Shakers, SOCD, and Keep Awake settings, and CopySlotConfigsAcrossSlots clones the per-device lighting configs, plus the Extended layout, MIDI layout, or Keyboard + Mouse config when both slots share that type. In that clone, a target device the source slot does not have takes a stand-in source device's config and keeps its own Control This Device’s Lighting switch (#494).
DevicesPage¶
Files: DevicesPage.xaml, DevicesPage.xaml.cs
All detected input devices with raw input state visualization.
Layout Structure¶
Grid (Margin="24,16")
├─ Row 0 (Auto): Header
│ ├─ Icon (E772) + Title
│ ├─ Refresh Button (E72C, RefreshCommand)
│ ├─ Pair Button (E702, PairCommand → PairDeviceDialog)
│ └─ Online/Total count display (telemetry mono)
└─ Row 1 (*): Main content (Grid, 2 columns)
├─ Col 0 (*): Facet chips + device card ListBox + drag-assign hint
│ ├─ Row 0: Type facet chips (ALL / GAMEPAD / JOYSTICK / WHEEL / KEYBOARD /
│ │ MOUSE / OTHER, ClickBorder radio chips, Click="FacetChip_Click", count per chip)
│ ├─ Row 1: ListBoxItem with custom ControlTemplate (4px accent left bar on selection)
│ │ └─ Card Border (CornerRadius=8, Padding="12,10")
│ │ ├─ Row 0, Col 0: liveness flame Path (LivenessFlameBase) + DeviceName (SemiBold, 13px)
│ │ ├─ Row 0-1, Col 1: Slot badges (WrapPanel of numbered badges).
│ │ │ No badges means unassigned. There is no fallback pill
│ │ ├─ Row 0-1, Col 2: Remove device Button (E711 × icon)
│ │ └─ Row 1: DeviceType + VID:PID (HasVidPid) + CapabilitiesSummary + battery (HasBattery)
│ └─ Row 2: Devices_DragAssignHint text
└─ Col 1 (340px): Detail panel (Border with ScrollViewer)
├─ DeviceName headline (CardTitle, wrapping)
├─ Device Dossier (#175 competitor item 7): eyebrow + copy Button
│ (CopyDossier_Click, E8C8) over a recessed telemetry-mono card whose
│ token rows run PRODUCT / TYPE / CAPS / APP GUID / SDL GUID /
│ PATH (HidHideInstancePath) / PATH (DossierConnectionPath) / VID:PID /
│ LINK / SERIAL / BATT. Rows whose fact is absent collapse. LINK reads
│ "BT" and shows only on IsBluetoothLink
├─ Capability chip strip (HasCapabilityIcons): rumble (E877, doubles as the
│ identify button via IdentifyChip_Click, #293), gyro (E7AD), touchpad (EFA5)
├─ Submit Mapping Button (joysticks only, opens GitHub issue template)
├─ Register / Manage NFC Tags Button (RegisterNfcTag_Click, ShowRegisterNfcTag, #150)
├─ Manage Voice Macros Button (ManageVoicePhrases_Click, ShowManageVoicePhrases, #317)
├─ Learn Handheld Buttons Button (LearnHandheldButton_Click,
│ ShowLearnHandheldButton, #343)
├─ Switch Driver Buttons (DriverBind_Click / DriverRestore_Click,
│ ShowDriverBind / ShowDriverRestore, SDL#33 Part 15)
├─ Read as iCade Controller / Read as Keyboard Buttons (ReadAsICade_Click /
│ ReadAsKeyboard_Click, ShowReadAsICade / ShowReadAsKeyboard, SDL#33 Part 16)
├─ Read as Tekken Sticks / Read as Taiko Drums Buttons (UsioTekken_Click /
│ UsioTaiko_Click, ShowUsioTekken / ShowUsioTaiko, SDL#33 Part 14)
├─ Bliss-Box line and actions (BlissBoxLine, BlissBoxPlayer_Click /
│ DreamcastScreen_Click / PakBackup_Click / PakRestore_Click, Read Arrows
│ One by One CheckBox with NativeArrows_Click, shown on ShowBlissBoxPlayer /
│ ShowDreamcastScreen / ShowControllerPak / ShowNativeArrows, #469)
├─ Keyboard screen line and button (KeyboardScreenLine, and KeyboardScreen_Click on a
│ button that reads KeyboardScreenButtonText, shown on ShowKeyboardScreen, #519)
├─ Falcon line, Calibrate Reach and its result (FalconLine, FalconCalibrate_Click,
│ FalconCalibrationStatus as a polite live region, shown on ShowFalconCalibrate, #521)
├─ GameModLine and Clear Mod Effects (ClearModEffects_Click), both collapsed while
│ GameModLine is empty (#529)
├─ AnalogKeyboardLine (#468, collapsed when empty)
├─ HandheldDaemonWarning line (HasHandheldDaemonWarning, #343)
├─ FlydigiServiceWarning line (HasFlydigiServiceWarning, #395)
├─ HeadTrackerStatus line (#355, collapsed when empty)
├─ GameTelemetryStatus line (#531, a polite live region, collapsed when empty)
├─ Separator
├─ VC Assignment section
│ ├─ WrapPanel of ToggleButtons (ActiveSlotItems, ToggleSlotCommand)
│ └─ Devices_NoSlotsHint line while no slot exists
├─ Input Mode section (ShowInputModeSection)
│ └─ "Force raw joystick mode" CheckBox (ForceRawJoystickMode)
├─ Input Hiding section (ShowInputHidingSection)
│ ├─ "Hide from Games (HidHide)" CheckBox (HidHideEnabled, HidingToggle_Click,
│ │ shown on ShowHidHideToggle, enabled on IsHidHideAvailable)
│ ├─ TabletInputStatus line (ShowTabletCaptureStatus)
│ └─ "Consume Mapped Inputs (Hooks)" CheckBox (ConsumeInputEnabled, ShowConsumeToggle)
├─ Separator (ShowInputModeOrHidingSection)
├─ Power section (ShowPowerSection)
│ ├─ Idle Disconnect minutes TextBox (IdleDisconnectMinutes,
│ │ ShowIdleDisconnect, #162)
│ └─ Quick Charge CheckBox (QuickChargeEnabled, ShowQuickCharge,
│ QuickCharge_Click, #372)
├─ Light Gun section (ShowGunCalibration, SDL#33 Part 9, Wii Remote #485)
│ ├─ Separator (ShowGunCalibrationDivider)
│ ├─ GunCalibrationStatus line
│ └─ Calibrate Button (GunCalibrate_Click, CanCalibrateGun,
│ ToolTip GunCalibrateTooltip) + reset
│ (GunCalibration)
├─ Separator (ShowRawInputDivider)
└─ Raw Input State section
├─ Falcon handle (#521, IsFalconDevice): a dot in a square (FalconPreviewBorder,
│ FalconDot) for Left/Right and Up/Down, beside the named axes
├─ Axes (joysticks/gamepads, hidden for keyboard/mouse)
│ └─ ItemsControl → ProgressBar per axis (0-1, name + bar + raw value)
├─ Buttons (joysticks/gamepads. Hidden for keyboard, mouse, MIDI, NFC,
│ microphone, consumer-control, motion-only, and head-tracker rows,
│ and while ShowNamedButtons)
│ └─ WrapPanel of 24×24 circles, accent fill when pressed
├─ NFC named-tag preview (#150, IsNfcDevice)
│ └─ ItemsControl → NfcTags (registered named tags)
├─ Consumer Control named-chip preview (#168, IsConsumerDevice)
│ └─ ItemsControl → ConsumerButtons (named button chips)
├─ Keyboard layout (keyboard devices only)
│ └─ Viewbox → Canvas (556×136) with positioned key Borders
├─ Mouse preview (mouse devices only)
│ └─ Viewbox → MousePreviewControl
├─ MIDI preview (MIDI input devices)
│ └─ MidiPreviewView ×2 (MidiNotesPreview, MidiCcPreview)
├─ D-Pad / POV hats (conditional on RawPovs.Count > 0)
│ └─ WrapPanel of compass indicators, three to a row in the 340 pane
│ ├─ 36×36 Ellipse background + center dot
│ └─ Accent-colored Line with RotateTransform(AngleDegrees), hidden when IsCentered
├─ Gyroscope (HasGyroData, 3-column X/Y/Z grid, telemetry mono F3 format)
├─ Accelerometer (HasAccelData, same layout as gyro)
├─ Aux Accelerometer (HasAccelAuxData, same layout, the Nunchuk sensor / combined pair's left-half accel, #199)
├─ Aux Gyro (HasGyroAuxData, same layout, the combined pair's left Joy-Con, #252)
├─ Touchpad preview (HasTouchpadData, up to 5 contact dots per pad,
│ TouchpadPreviewBorder, plus Touchpad2PreviewBorder on HasSecondTouchpadData)
├─ Named button chips (ShowNamedButtons: handheld hidden buttons #343,
│ VR controllers, Logitech G-Keys rows)
│ └─ HandheldButtons chips, or the Handheld_NoneLearned line when empty
└─ Voice phrases (#317, ShowVoicePhrases)
└─ ItemsControl → VoicePhrases (registered phrase chips)
Key Bindings¶
| Binding | ViewModel | Description |
|---|---|---|
Devices |
DevicesViewModel |
Device list collection |
SelectedDevice |
DevicesViewModel |
Currently selected device row |
HasSelectedDevice |
DevicesViewModel |
Controls detail panel visibility |
OnlineCount / TotalCount |
DevicesViewModel |
Header device counts |
RefreshCommand |
DevicesViewModel |
Refresh button |
ActiveSlotItems |
DevicesViewModel |
Slot toggle button items |
ToggleSlotCommand |
DevicesViewModel |
Toggle device-to-slot assignment |
RemoveDeviceCommand |
DevicesViewModel |
Remove device from list |
SelectedFacet |
DevicesViewModel |
Active type facet chip. FacetCountGamepad and friends fill the per-chip counts |
Device Card Bindings (DeviceRowViewModel)¶
| Binding | Description |
|---|---|
IsOnline |
Drives a liveness-flame Path (fills EmberBrush with a glow when online, LivenessFlameBase outline stroke when offline). The device-name text also dims when offline. |
DeviceName |
Bold device name |
SlotBadges |
Collection of slot assignment badges. Absence of badges encodes unassigned (#175 phase 2 item 9 removed the IsUnassigned flag and its gray fallback pill) |
DeviceType |
Type string |
VendorIdHex / ProductIdHex |
Hex VID:PID |
CapabilitiesSummary |
Devices_CapsSummary_Format, e.g. "6 axes, 11 buttons, 1 POV(s)" |
BatteryGlyph / BatteryText |
Battery indicator (#167): Segoe MDL2 Assets glyph + "78%", hidden when HasBattery is false |
Detail Panel Bindings (SelectedDevice)¶
| Binding | Description |
|---|---|
DeviceName, ProductName, DeviceType |
Device identity (dossier PRODUCT / TYPE rows) |
CapabilitiesSummary |
Dossier CAPS row |
InstanceGuid |
Dossier APP GUID row |
SdlGuid, DossierConnectionPath, SerialNumber |
Dossier SDL GUID / LINK / SERIAL rows, each collapsed via StringToVisibility when empty |
HidHideInstancePath |
Dossier PATH row (conditional visibility via StringToVisibility) |
ShowSubmitMapping |
Submit mapping button visibility (joysticks only) |
ShowInputModeSection |
Input Mode section visibility (_forceRawJoystickMode \|\| (IsGamepad && !IsInternalVirtual): a row whose flag is on shows the section whatever its type, so it can be turned off) |
ShowInputHidingSection |
Input Hiding section visibility (ShowHidHideToggle \|\| ShowConsumeToggle) |
ShowHidHideToggle |
HidHide row visibility (!IsInternalVirtual && !IsAggregate: a merged row has no HID instance to cloak) |
ShowTabletCaptureStatus / TabletInputStatus |
Tablet capture status line under the HidHide row |
HasFlydigiServiceWarning / FlydigiServiceWarning |
Flydigi Space Station service notice (#395) |
ForceRawJoystickMode |
Force raw toggle |
IsHidHideAvailable |
Enables/disables HidHide checkbox |
HidHideEnabled |
HidHide toggle |
ShowConsumeToggle |
Consume toggle visibility (mouse/keyboard devices) |
ConsumeInputEnabled |
Consume toggle |
ShowPowerSection |
Power section visibility. True when either row below draws |
ShowIdleDisconnect / IdleDisconnectMinutes |
Idle-disconnect row visibility and its countdown minutes (#162) |
ShowQuickCharge / QuickChargeEnabled |
Quick Charge row visibility and toggle (#372). Also true on a Sony record the USB cable rebound to its wired path, which is not a disconnect target and is exactly when the feature fires |
ShowInputModeOrHidingSection / ShowRawInputDivider |
The two conditional separators around the Power section |
ShowGunCalibration / ShowGunCalibrationDivider / GunCalibrationStatus / CanCalibrateGun / GunCalibrateTooltip |
Light Gun section visibility, the rule above it, the status line (a GunCon 2's aim range, or whether a Wii Remote is calibrated), the Calibrate button's enable state, and its tooltip by kind of gun |
ShowReadAsICade / ShowReadAsKeyboard |
The iCade pair of buttons |
ShowUsioTekken / ShowUsioTaiko |
The USIO layout buttons |
HasCapabilityIcons / HasRumble / HasGyro / ShowTouchpadCapability |
Capability chip strip and its three chips |
ShowLearnHandheldButton / HandheldDaemonWarning / HasHandheldDaemonWarning |
Learn Handheld Buttons button and the vendor-daemon notice (#343) |
ShowRegisterNfcTag |
Register/Manage NFC Tags button visibility (#150) |
ShowManageVoicePhrases |
Manage Voice Macros button visibility (#317): a standalone microphone row, or a DualSense / DualSense Edge over Bluetooth where the pad itself carries the phrases. Never for a peer:// path, since recognition runs on the owner |
RawAxes |
Axis ProgressBar items |
RawButtons |
Button circle items |
IsKeyboardDevice / IsMouseDevice |
Switches between button circles, keyboard canvas, or mouse graphic |
KeyboardKeys |
QWERTY keyboard layout items |
RawPovs |
POV compass items |
HasGyroData / HasAccelData / HasAccelAuxData / HasGyroAuxData |
Gyro / accel / aux-accel / aux-gyro section visibility |
GyroX/Y/Z / AccelX/Y/Z / AccelAuxX/Y/Z |
Motion sensor values (aux accel is #199, aux gyro is #252) |
HasTouchpadData / HasSecondTouchpadData / TouchpadLabel |
Touchpad preview visibility for each pad, and the caption |
The raw-state rows above bind to the page DataContext (DevicesViewModel), which republishes the selected device's live state. The NFC, Voice, Consumer, and handheld previews do the same: IsNfcDevice / NfcTags, ShowVoicePhrases / VoicePhrases (#317, the voice twin of the NFC tag rows), IsConsumerDevice / ConsumerButtons, and IsHandheldDevice (#343). HeadTrackerStatus and GameTelemetryStatus are page-scoped too. Everything under the dossier and the sections below it, including ShowRegisterNfcTag, ShowQuickCharge, and IdleDisconnectMinutes, is SelectedDevice-scoped and binds through the SelectedDevice. prefix.
Selection Bar¶
Custom ListBoxItem ControlTemplate:
- SelectionBar. 4px Border with accent brush on left edge, CornerRadius="2".
- Toggled by IsSelected trigger.
- Content offset 6px right to accommodate the bar.
Event Handlers (Code-Behind)¶
| Handler | Trigger | Action |
|---|---|---|
RemoveDevice_Click |
Button.Click | Selects device, executes RemoveDeviceCommand |
FacetChip_Click |
ClickBorder.Click | Sets SelectedFacet from the chip's Tag, filtering the device list |
HidingToggle_Click |
CheckBox.Click | Shows the warning flyout when enabling on a keyboard, mouse, or consumer-control device, clears LastRawStateDeviceGuid for rebuild, calls NotifyDeviceHidingChanged |
ShowHidingWarningFlyout |
(internal) | WPF UI Flyout with warning icon, message, Proceed/Cancel buttons. Reverts checkbox immediately, re-checks only on Proceed. |
SubmitMapping_Click |
Button.Click | Opens browser to GitHub issue template with device info pre-filled |
CopyDossier_Click |
Button.Click | Copies the device dossier's token rows to the clipboard |
RegisterNfcTag_Click |
Button.Click | Opens RegisterNfcTagDialog for the selected NFC reader (#150) |
ManageVoicePhrases_Click |
Button.Click | Opens RegisterVoicePhraseDialog for the selected microphone-carrying device (#317) |
LearnHandheldButton_Click |
Button.Click | Opens LearnHandheldButtonDialog for the machine's hidden buttons (#343) |
IdentifyChip_Click |
ClickBorder.Click | Forwards to IdentifyDevice_Click, which calls InputService.IdentifyDevice(SelectedDevice.InstanceGuid) to vibrate the pad (#293) |
QuickCharge_Click |
CheckBox.Click | Persists the Quick Charge toggle. Click, not Checked, so the binding-driven refresh on a selection change never writes (#372) |
IdleDisconnect_LostFocus |
TextBox.LostFocus | Applies the clamped idle-disconnect minutes (#162) |
DeviceCard_MouseDown |
PreviewMouseLeftButtonDown | Records drag start position. Skips if inside a Button |
DeviceCard_MouseMove |
PreviewMouseMove | Initiates DragDrop.DoDragDrop with DeviceInstanceGuid data when threshold exceeded |
DeviceCard_MouseUp |
PreviewMouseLeftButtonUp | Resets drag state |
Device Drag to Sidebar¶
Device cards support drag via mouse events. Drag data is a DataObject with key "DeviceInstanceGuid" and value device.InstanceGuid. Drop on a sidebar controller card assigns the device to that slot.
KBMPreviewView¶
Files: KBMPreviewView.xaml, KBMPreviewView.xaml.cs
Keyboard and mouse preview for Keyboard+Mouse virtual controller slots, shown on the PadPage Preview tab.
Layout¶
Two horizontal Canvas areas:
- KeyboardCanvas. QWERTY layout built from
KeyboardKeyItem.BuildLayout(). Each key is aBorder+TextBlock, absolutely positioned. Keys highlight with accent color when pressed in the output state. - MouseCanvas. Stylized mouse graphic: contoured LMB/RMB paths, scroll wheel pill, movement circle with deflection dot, scroll arrows, and X1/X2 side buttons.
Interaction¶
All elements are clickable for click-to-record. Fires ControllerElementRecordRequested with the target name (e.g., KbmKey41, KbmMBtn0, KbmMouseX). Hover highlights use ember (HoverBrush #FFA24D). Recording targets flash at 400ms with orange (FlashBrush #FFA500). Pressed keys light ember (KeyPressedBrush #FF6B2C).
Rendering¶
Uses CompositionTarget.Rendering with a _dirty flag. Per frame:
- Keyboard keys: reads KbmOutputSnapshot.GetKey() per VK index, sets accent background on pressed keys.
- Mouse buttons: reads GetMouseButton() for LMB/RMB/MMB/X1/X2.
- Movement dot: maps MouseDeltaX/MouseDeltaY to deflection within the movement circle.
- Scroll arrows: lights up/down arrows based on ScrollDelta sign.
Theme-Aware Brushes¶
Pre-cached, frozen static readonly dark and light brush variants for key backgrounds, borders, and text, picked by theme when used. OnRendering compares the app theme each frame and rebuilds the layout when it changed, so no per-frame DynamicResource lookup happens.
Tooltip Helper¶
MappingLabel() resolves target setting names to human-readable labels from the mapping table, falling back to the raw name. X1/X2 side button Rectangle elements are promoted to named fields for flash support.
MidiPreviewView¶
Files: MidiPreviewView.xaml, MidiPreviewView.xaml.cs
MIDI note and CC visualization for MIDI virtual controller slots, shown on the PadPage Preview tab. The same control serves the Devices page twice (MidiNotesPreview, MidiCcPreview) for a physical MIDI input device's raw state.
Layout¶
Single Canvas (MidiCanvas), rebuilt when MidiSlotConfig properties change (start note, note count, start CC, CC count):
- CC Sliders. Vertical bars, one per CC. Background rectangle + fill rectangle proportional to value (0-127) + CC number label.
- Piano Keyboard. Standard chromatic layout: white keys full-height underneath, black keys shorter on top (higher Z-index). White keys show note name + octave (e.g., "C4", "D4").
Interaction¶
CC sliders and piano keys are clickable for click-to-record (fires ControllerElementRecordRequested with MidiCC{index} or MidiNote{index}). Hover highlights and 400ms flash timer match other preview views.
Rendering¶
Uses CompositionTarget.Rendering with a _dirty flag. Per frame:
- CC sliders: reads MidiOutputSnapshot.CcValues[], scales fill height to 0-100%.
- Piano keys: reads MidiOutputSnapshot.Notes[] boolean array, applies the pressed brush (ember-hot #FFA24D for white keys, ember-deep #C43D0C for black keys) on active notes.
Theme-Aware Brushes¶
Pre-cached, frozen static readonly dark and light brush variants for CC bar fills, piano key surfaces, and label text, picked by theme when used. The canvas is rebuilt when the theme changes, which avoids per-frame DynamicResource overhead.
Layout Rebuild¶
The entire canvas is cleared and rebuilt on any MidiSlotConfig property change. No partial layout updates.
VRPreviewView¶
Files: VRPreviewView.xaml, VRPreviewView.xaml.cs
Live preview for a VR slot (#49), shown on the PadPage Preview tab. One VR slot drives both SteamVR hands, so the art is the pair side by side rather than a single controller body.
Layout¶
Viewbox over a single Canvas (VrCanvas), built once on Loaded from the 2D art in 2DModels/VRCONTROLLER/. Base art is 975×726, and the twelve elements are positioned in those pixels:
| Element | Targets |
|---|---|
VRController_L_Stick / _R_Stick |
VrLStick / VrRStick (click plus four directions) |
VRController_L_A / _R_A |
VrLA / VrRA (inner press disc, outer touch ring) |
VRController_L_B / _R_B |
VrLB / VrRB |
VRController_L_System / _R_System |
VrLSystem / VrRSystem (single-target) |
VRController_L_Trigger / _R_Trigger |
VrLTrigger / VrRTrigger (body axis, tip band click) |
VRController_L_Grip / _R_Grip |
VrLGrip / VrRGrip |
Rendering¶
Element tinting follows the Rectangle + ImageBrush OpacityMask idiom the branded 2D packs use: the cutout supplies the shape and one brush supplies the color, so lit, hover, and flash all drive the same layer. Triggers and grips fill bottom-up through a RectangleGeometry clip rather than scaling opacity. Stick caps translate up to 14px with deflection, and the stick's tint layer and region highlight share that transform. Frames come from CompositionTarget.Rendering against a cached VrRawState.
Interaction¶
Same click-to-record contract as the other previews, including Bind(vm) / Unbind() and ControllerElementRecordRequested. Every element except System carries more than one target, so each one gets a region highlight: the element's own overlay clipped to the region under the pointer, at the drawn packs' 0.4 hover opacity.
MousePreviewControl¶
Files: MousePreviewControl.xaml, MousePreviewControl.xaml.cs
Read-only mouse graphic for mouse-type devices on the Devices page detail pane.
Layout¶
Built once on Loaded into Canvas (MouseCanvas). Same mouse shape as KBMPreviewView but without click-to-record:
- LMB/RMB. Contoured
Pathelements flanking the scroll wheel. - Scroll wheel. Rounded
Rectanglebetween buttons, with up/down arrowPolygonindicators. - Movement circle.
Ellipsewith deflection dot tracking live mouse delta. - X1/X2 side buttons. Small
Rectangleelements on the left edge.
Theme-Aware Brushes¶
Pre-cached, frozen static readonly dark and light brush variants for mouse body, button fills, and indicator colors. BuildMouse() reruns when the theme changes, consistent with KBMPreviewView and MidiPreviewView.
Rendering¶
Uses CompositionTarget.Rendering (no dirty flag. Every frame). Reads from DevicesViewModel:
- Buttons: RawButtons[0..4].IsPressed mapped to LMB, MMB, RMB, X1, X2.
- Movement: MouseMotionX/MouseMotionY (normalized) to dot deflection.
- Scroll: MouseScrollIntensity drives arrow fill, opacity, and scale. Arrows grow and brighten with scroll magnitude.
SettingsPage¶
Files: SettingsPage.xaml, SettingsPage.xaml.cs
Application settings in vertical CardBorder sections.
Card order is pinned by Settings_CardsRunInTheDecidedOrder in PadForge.Tests/PageOrderContractTests.cs, which asserts each card title's binding appears once and after the one before it. Nearly every setting row carries a SettingResetButton bound to ResetSettingCommand with the property name as CommandParameter.
Layout Structure¶
ScrollViewer (Padding="24,0")
└─ StackPanel (Margin="0,16,0,16")
├─ Page header (E713 gear icon + title)
├─ Language card (CardBorder)
│ ├─ Icon F2B7 + "Language" title + description
│ └─ ComboBox (AvailableLanguages, SelectedValuePath="Culture", TextSearch.TextPath="NativeName", Width=340.
│ Each row shows NativeName with EnglishNameIfDifferent beside it in the tertiary brush and declares its own language)
├─ Appearance card
│ ├─ Icon E790 + "Appearance" title + description
│ ├─ "Theme" label
│ ├─ ComboBox (System Default / Light / Dark, SelectedIndex=SelectedThemeIndex)
│ └─ "Show Welcome Tour" Button (Settings_ShowTour, ShowTour_Click) → re-runs the first-run spotlight tour
├─ Window card
│ ├─ Icon E737 + title + description
│ ├─ Minimize to tray (MinimizeToTray)
│ ├─ Close to tray (CloseToTray, reset through ResetCloseToTrayCommand)
│ ├─ Always show in system tray (AlwaysShowTrayIcon, #439)
│ ├─ Start minimized (StartMinimized)
│ └─ Start at login (StartAtLogin)
├─ Updates card (#457)
│ ├─ Icon E896 + title + description
│ ├─ Check for updates automatically (CheckForUpdatesAutomatically)
│ ├─ Install updates automatically (InstallUpdatesAutomatically, shown only while
│ │ the automatic check is on)
│ ├─ Include pre-releases (IncludePreReleaseUpdates)
│ ├─ UpdateStatusText (hidden while empty, ember while IsUpdateAvailable)
│ ├─ ProgressBar (UpdateProgress 0-100, shown while IsUpdateDownloading)
│ └─ Check Now (CheckForUpdatesNowCommand), plus Install and Restart
│ (InstallUpdateCommand, EmberAccentButton) and Release Notes
│ (OpenReleaseNotesCommand), both shown only while IsUpdateAvailable
├─ Input Engine card
│ ├─ Icon E9F5 + title + description
│ ├─ Auto-start toggle (AutoStartEngine)
│ ├─ Background polling toggle (EnablePollingOnFocusLoss)
│ ├─ Flydigi Enhanced Protocol toggle (FlydigiEnhancedProtocol)
│ ├─ Read Logitech G-Keys toggle (GKeysEnabled, #454) + GKeysStatus line
│ ├─ Read Analog Keyboards toggle (AnalogKeyboardsEnabled, #468) + AnalogKeyboardsStatus line
│ ├─ Read Bliss-Box Adapters toggle (BlissBoxEnabled, #469) + BlissBoxStatus line
│ ├─ Polling interval: NumberBox 1-16ms (PollingRateMs) + "ms"
│ ├─ PollingOverrideNote (ember, shown only while the active profile
│ │ overrides the global value, #365)
│ └─ HM inactivity timeout: NumberBox 0-3600s
│ (HmInactivityDestroyTimeoutSeconds, 0 = never)
├─ Assignment Prompts card
│ ├─ Icon E8DE + title + description
│ ├─ Offer on a new device (AssignOfferNewDevice)
│ └─ Offer on an empty slot (AssignOfferEmptySlot)
├─ Handheld PC Buttons card (#343)
│ ├─ Icon E7F8 + title + description
│ └─ Enable toggle (HandheldButtonsEnabled). On, the Devices page gains
│ the machine's Hidden Buttons row and its System Motion row
├─ Keyboard Screens card (#519)
│ ├─ Drive Keyboard Screens toggle (KeyboardScreensEnabled) + KeyboardScreensStatus,
│ │ lines for the feature as a whole. Each screen's line and dialog are on its
│ │ Devices row
│ └─ S.T.R.I.K.E. 7 library buttons (DownloadStrike7LibraryCommand,
│ PickStrike7LibraryCommand, InstallVc2008RuntimeCommand)
├─ Novint Falcon card (#521)
│ ├─ Drive Novint Falcon toggle (NovintFalconEnabled) + FalconStatus: the library
│ │ that loaded, and why no Falcon opened while none did. It names no Falcon
│ └─ Choose dhd64.dll… (PickFalconLibraryCommand), Install Falcon Driver
│ (InstallFalconDriverCommand, shown on FalconCanInstallDriver) and Get Force
│ Dimension's SDK (OpenFalconDownloadCommand)
├─ Battery Alerts card (#293)
│ ├─ Icon E83F + title + description
│ ├─ "Notify When Battery Runs Low" toggle (BatteryNotifyEnabled)
│ ├─ "Notify at or Below" NumberBox 5-50 % (BatteryNotifyThreshold)
│ ├─ "Also Vibrate the Controller" toggle (BatteryNotifyVibrate)
│ └─ "Test Notification" Button (TestBatteryNotify_Click)
├─ HidHide Driver card
│ ├─ Icon ED1A + title + description
│ ├─ Status: flame + HidHideStatusText + HidHideVersion
│ ├─ Install/Uninstall buttons (visibility-toggled by IsHidHideInstalled)
│ ├─ Hide devices toggle (EnableInputHiding, only when installed)
│ ├─ Keep cloaks between launches toggle (KeepHidHideCloaksBetweenLaunches, only when installed)
│ └─ Whitelist section (only when installed):
│ ├─ Title + description
│ ├─ ListBox of HidHideWhitelistPaths (telemetry mono, 12px)
│ └─ Add/Remove buttons
├─ HIDMaestro Driver card
│ ├─ Icon E7FC + title + description
│ └─ Status: ember flame lit while the driver is in the driver store + "Installed" or
│ "Not Installed" + HIDMaestroVersion, and Uninstall, which opens UninstallHIDMaestroDialog
├─ BthPS3 Bluetooth Driver card
│ ├─ Icon E702 + title + description
│ └─ Status: ember flame lit while either BthPS3 driver is a service + "Installed" or
│ "Not Installed" + BthPS3Version, and Uninstall, which opens UninstallBthPS3Dialog
├─ Windows MIDI Services card
│ ├─ Icon E8D6 + title + description
│ ├─ Status: flame (lit while IsMidiAvailable) + MidiServicesStatusText + MidiServicesDetailText
│ │ (collapsed when empty, telemetry face while MidiDetailIsVersion)
│ ├─ Install button (InstallMidiRuntimeCommand, shown while CanInstallMidiRuntime)
│ └─ Uninstall button (UninstallMidiServicesCommand, shown while IsMidiRuntimeInstalled)
├─ SteamVR card (#49)
│ ├─ Icon F119 + title + description
│ ├─ Status: flame (lit while IsSteamVrInstalled) + SteamVrStatusText (Not Installed,
│ │ Installed, or the runtime / driver / live-controller state)
│ ├─ Install directory TextBox + reset + Browse button (SteamVrBrowse_Click →
│ │ SteamVrInstallDir), shown only while SteamVR is not installed
│ └─ Install button (InstallSteamVrCommand, while not installed) and Uninstall button
│ (UninstallSteamVrCommand, while ShowSteamVrUninstall: installed and PadForge-owned)
├─ Community Configs card (#9)
│ ├─ Icon E716 (EmberBrush) + title + description (the endpoint / privacy statement)
│ ├─ Enable Community Configs checkbox (EnableCommunityConfigLookup)
│ ├─ Show Legacy Workshop Configs checkbox (ShowLegacyWorkshopConfigs, visible only when enabled)
│ └─ Clear Cached Configs + Check Imported Profiles for Updates buttons
├─ Settings File card
│ ├─ Icon E8A5 + title + description
│ ├─ SettingsFilePath (telemetry mono, wrapping)
│ ├─ Save / Reload / Reset to Defaults / Open Folder buttons
│ └─ "Unsaved changes" warning (orange, HasUnsavedChanges)
└─ Diagnostics card
├─ Icon E9D9 + title + description
├─ Grid (140px label + value):
│ ├─ App Version (ApplicationVersion = BuildIdentity.Display, e.g. "4.5.3 (r3682@176208e)")
│ ├─ .NET Runtime (RuntimeVersion)
│ └─ SDL Version (SdlVersion)
├─ "Keep a Diagnostics Log" toggle (DiagnosticsLoggingEnabled) (#303)
├─ Log folder path readout (DiagnosticsFolderPath)
└─ "Save Snapshot" + "Open Log Folder" buttons (SaveDiagSnapshot_Click / OpenDiagFolder_Click)
Key Bindings¶
| Binding | Target | Description |
|---|---|---|
AvailableLanguages |
ComboBox ItemsSource | Language options with NativeName display. The row style sets each row's Language through CultureLanguageConverter, so a Chinese, Japanese or Korean name is drawn in its own font (#504). |
SelectedLanguage |
ComboBox SelectedItem | Current language |
SelectedThemeIndex |
ComboBox SelectedIndex | 0=System, 1=Light, 2=Dark |
AutoStartEngine |
CheckBox | Auto-start engine on launch |
EnablePollingOnFocusLoss |
CheckBox | Continue polling when app loses focus |
PollingRateMs |
NumberBox (1-16) | Polling interval in ms |
HmInactivityDestroyTimeoutSeconds |
NumberBox (0-3600) | Seconds of device inactivity before the VC is torn down. 0 means never |
EnableInputHiding |
CheckBox | Master input hiding toggle (HidHide card) |
KeepHidHideCloaksBetweenLaunches |
CheckBox | Leave cloaks in place across app restarts |
MinimizeToTray |
CheckBox | Minimize to system tray |
CloseToTray / AlwaysShowTrayIcon |
CheckBox | Close to the tray instead of exiting, and keep the tray icon visible while the window is open (#439) |
StartMinimized |
CheckBox | Start app minimized |
StartAtLogin |
CheckBox | Start at Windows login |
CheckForUpdatesAutomatically / InstallUpdatesAutomatically / IncludePreReleaseUpdates |
CheckBox | Updates card toggles (#457). See Updates and Updates Internals |
UpdateStatusText / IsUpdateAvailable / IsUpdateDownloading / UpdateProgress |
string, bool, bool, number | Updates card status line, button visibility, and download progress |
CheckForUpdatesNowCommand / InstallUpdateCommand / OpenReleaseNotesCommand |
ICommand | Check Now, Install and Restart, Release Notes |
FlydigiEnhancedProtocol / GKeysEnabled / GKeysStatus |
CheckBox, CheckBox, string | Input Engine card: Flydigi vendor protocol, Logitech G-Keys input, and the G-Keys status line |
AnalogKeyboardsEnabled / AnalogKeyboardsStatus |
CheckBox, string | Input Engine card: Read Analog Keyboards (#468) and its line, which names no keyboard. A keyboard's own line is on its Devices row |
BlissBoxEnabled / BlissBoxStatus |
CheckBox, string | Input Engine card: Read Bliss-Box Adapters (#469) and its line, which names no port. Each port's row names its controller |
KeyboardScreensEnabled / KeyboardScreensStatus |
CheckBox, string | Keyboard Screens card (#519): the switch and the feature's lines |
DownloadStrike7LibraryCommand / PickStrike7LibraryCommand / InstallVc2008RuntimeCommand |
ICommand | The S.T.R.I.K.E. 7 library buttons |
NovintFalconEnabled / FalconStatus / FalconCanInstallDriver |
CheckBox, string, bool | Novint Falcon card (#521): the switch, the library line and why no Falcon opened, and Install Falcon Driver's visibility |
PickFalconLibraryCommand / InstallFalconDriverCommand / OpenFalconDownloadCommand |
ICommand | Choose dhd64.dll…, Install Falcon Driver, Get Force Dimension's SDK |
BatteryNotifyEnabled / BatteryNotifyThreshold / BatteryNotifyVibrate |
CheckBox, NumberBox (5-50), CheckBox | Battery Alerts card: low-battery toast on/off, the percent threshold, and the optional controller vibration (#293) |
IsHidHideInstalled |
bool | Controls status flame, button visibility, whitelist section |
InstallHidHideCommand / UninstallHidHideCommand |
ICommand | Driver install/uninstall |
HidHideWhitelistPaths |
Collection | Whitelist ListBox items |
SelectedWhitelistPath |
object | Selected whitelist item |
AddWhitelistPathCommand / RemoveWhitelistPathCommand |
ICommand | Whitelist management |
IsMidiAvailable / IsMidiRuntimeInstalled / MidiDetailIsVersion |
bool | Windows MIDI Services card: the flame, the Uninstall button's visibility, and the detail line's telemetry face |
MidiServicesStatusText / MidiServicesDetailText |
string | The card's status line and the line under it |
CanInstallMidiRuntime / InstallMidiRuntimeCommand / UninstallMidiServicesCommand |
bool, ICommand, ICommand | The Install button's visibility, and the runtime's install and uninstall |
IsSteamVrInstalled / IsSteamVrOwned / SteamVrInstallDir |
bool, bool, string | SteamVR card status, whether PadForge created the Steam-free install, and its directory (#49) |
SteamVrStatusText / ShowSteamVrUninstall |
string, bool | SteamVR status line, and the Uninstall gate (IsSteamVrInstalled && IsSteamVrOwned) |
InstallSteamVrCommand / UninstallSteamVrCommand |
ICommand | SteamVR install/uninstall |
SaveCommand / ReloadCommand / ResetCommand / OpenSettingsFolderCommand |
ICommand | Settings file operations |
EnableCommunityConfigLookup / ShowLegacyWorkshopConfigs |
CheckBox | Steam Workshop opt-in + legacy sub-toggle (#9) |
ClearWorkshopCacheCommand / CheckWorkshopUpdatesCommand |
ICommand | Workshop cache clear and imported-profile update check |
HasUnsavedChanges |
bool | Orange warning visibility |
DiagnosticsLoggingEnabled / DiagnosticsFolderPath |
CheckBox, string | Diagnostics card: continuous engine-event log toggle and the folder it writes to (#303) |
ApplicationVersion |
string | Diagnostics App Version row. MainWindow sets it to BuildIdentity.Display |
ResetSettingCommand |
RelayCommand<string> |
Per-row reset for the named setting |
Code-Behind¶
Five handlers beyond the constructor. Everything else lives in the ViewModel.
| Handler | Trigger | Action |
|---|---|---|
TestBatteryNotify_Click |
Battery Alerts card button | Calls InputService.TestBatteryNotification() to fire the low-battery pipeline with a synthetic event (#293) |
SaveDiagSnapshot_Click |
Diagnostics card button | DiagnosticsLogControl.SaveSnapshot() dumps the in-memory engine event ring to a timestamped file and selects it in Explorer (#303) |
OpenDiagFolder_Click |
Diagnostics card button | Creates the diagnostics folder if needed and opens it in Explorer (#303) |
ShowTour_Click |
Appearance card button | Calls MainWindow.StartFirstRunTour() to re-run the welcome tour (#175) |
SteamVrBrowse_Click |
SteamVR card button | OpenFolderDialog for the Steam-free SteamVR install directory, writes SettingsViewModel.SteamVrInstallDir (#49) |
ProfilesPage¶
Files: ProfilesPage.xaml, ProfilesPage.xaml.cs
Per-app profile management.
Layout Structure¶
ScrollViewer (Padding="24,0")
└─ StackPanel (Margin="0,16,0,16")
├─ Page header (E8F1 people icon + title)
└─ CardBorder
├─ Icon E8B7 + "Management" title + description
├─ Auto-switch CheckBox (EnableAutoProfileSwitching)
├─ FOREGROUND live readout (visible only while auto-switch is on):
│ mono token + lit flame when IsForegroundMatched + ForegroundExeName,
│ plus the no-rules hint when NoProfileHasExecutables
├─ External Control CheckBox (EnableExternalControl, #366), off by default
├─ Drop-zone Grid (AllowDrop, ProfileList_DragEnter/DragOver/DragLeave/Drop
│ for .pfprofile import, ProfileDropOverlay cue)
│ └─ Profile card ListBox (ProfileListBox, WrapPanel of steel cards,
│ MinHeight=92, MaxHeight=452, MouseDoubleClick loads)
│ └─ ItemTemplate:
│ ├─ Profile Name (SemiBold) + "Built in" tag on the default
│ ├─ Executable names (FirstExecutableName, SecondExecutableName,
│ │ ExtraExecutablesSuffix, each collapsed when empty)
│ └─ Type count badges (horizontal StackPanel):
│ ├─ Xbox badge: Xbox SVG + XboxCount (collapsed when 0)
│ ├─ PlayStation badge: PS SVG + PlayStationCount (collapsed when 0)
│ ├─ Extended badge: Joystick SVG + ExtendedCount (collapsed when 0)
│ ├─ MIDI badge: E8D6 glyph + MidiCount (collapsed when 0)
│ ├─ KB+M badge: E961 glyph + KbmCount (collapsed when 0)
│ ├─ Nintendo badge: Switch SVG + NintendoCount (collapsed when 0)
│ ├─ VR badge: F119 glyph + VrCount (collapsed when 0)
│ ├─ "No slots" fallback (visible when HasNoSlots=True)
│ └─ Auto-switch rule chip (ChipCold, AutoSwitchRuleSummary, visible
│ when HasExecutables and auto-switch is on)
└─ Action buttons: New / Save As / Load / Edit / Export / Import /
Browse Starters / Browse Community / Delete
Key Bindings¶
| Binding | Target | Description |
|---|---|---|
EnableAutoProfileSwitching |
CheckBox | Enables foreground app monitoring |
EnableExternalControl |
CheckBox | Opens the named pipe that lets a launcher or script activate a profile (#366). It sits with the auto-switch and shortcut controls because it is a third way a profile activates, not a global engine service. The SettingsViewModel setter mirrors into SettingsManager.EnableExternalControl, which InputService watches to start or stop ExternalControlService. Switching profiles by hand releases the hold a script placed |
ForegroundExeName / IsForegroundMatched / NoProfileHasExecutables |
TextBlock, flame, hint | The FOREGROUND live readout. SettingsViewModel.ActiveProfileInfo is set by the services but is not bound on this page. The status bar's ProfilePill carries the active-profile name |
ProfileItems |
ListBox ItemsSource | Profile list |
SelectedProfile |
ListBox SelectedItem | Selected profile. SelectedProfile.IsDefault disables Edit and Delete |
NewProfileCommand |
Button | Create new profile |
SaveAsProfileCommand |
Button | Save current config as profile |
EditProfileCommand |
Button | Edit profile name/exes |
LoadProfileCommand |
Button | Load selected profile |
ExportProfileCommand / ImportProfileCommand |
Button | .pfprofile export and import |
BrowseStarterProfilesCommand |
Button | Opens StarterProfilesDialog (#256) |
BrowseCommunityConfigsCommand |
Button | Opens WorkshopBrowseDialog (#9) |
DeleteProfileCommand |
Button | Delete selected profile |
Profile Item Bindings¶
| Binding | Description |
|---|---|
Name |
Profile name (SemiBold) |
IsDefault |
Marks the built-in Default profile. Blocks Edit and Delete |
Executables |
Comma-separated exe list backing the card. HasExecutables gates the auto-switch rule chip |
AutoSwitchRuleSummary |
Profiles_AutoSwitchRule_Format over the exe list, shown in the cold auto-switch chip |
FirstExecutableName / SecondExecutableName / ExtraExecutablesSuffix |
The card renders at most two exe names plus a "+N more" suffix, each collapsed via StringToVisibility |
XboxCount / PlayStationCount / ExtendedCount / MidiCount / KbmCount / NintendoCount / VrCount |
Per-type counts (badge collapsed when 0) |
HasNoSlots |
Shows "No slots" badge when all seven type counts are zero |
Controller Shortcuts Card¶
Added below the profile management card. Provides per-shortcut combo recording and mode selection.
CardBorder (Margin="0,20,0,0")
└─ StackPanel
├─ Icon E71B + "Shortcuts" title + description
├─ ItemsControl (ItemsSource="{Binding ProfileShortcuts}")
│ └─ ItemTemplate (Grid, 5 columns):
│ ├─ Col 0 (3*, 120-328 px): Mode ComboBox (SwitchModes: Next / Previous /
│ │ Specific / ToggleWindow / ToggleVCsDisabled) + reset
│ ├─ Col 1 (2*, 120-178 px): Profile ComboBox (ProfileChoices) + reset,
│ │ visible only in Specific mode (IsSpecificMode)
│ ├─ Col 2 (3*, 120-328 px): Device ComboBox (DeviceChoices) + reset
│ ├─ Col 3: ButtonComboDisplay TextBlock (fills remaining, marquee-enabled)
│ └─ Col 4: Action buttons (Learn/Clear/Delete)
│ ├─ Learn: Click="ShortcutLearn_Click", icon toggles Record/Stop
│ ├─ Clear: Command="{Binding ClearCommand}", icon E75C
│ └─ Delete: Command="{Binding DeleteCommand}", icon E74D
└─ "Add Shortcut" Button (Click="AddShortcut_Click")
Event Handlers (Code-Behind)¶
| Handler | Trigger | Action |
|---|---|---|
ProfileList_MouseDoubleClick |
ListBox.MouseDoubleClick | Executes LoadProfileCommand |
ProfileList_DragEnter / DragOver / DragLeave / Drop |
Drop-zone Grid | Accepts a dropped .pfprofile file for import and drives the ProfileDropOverlay cue |
ShortcutLearn_Click |
Learn button Click | Starts 5-second combo recording for the row's ProfileShortcutViewModel |
ProfileChoices_DropDownOpened / DeviceChoices_DropDownOpened |
ComboBox.DropDownOpened | Rebuilds the shortcut row's profile and device lists on open |
AddShortcut_Click |
"Add Shortcut" button Click | Creates a new GlobalMacroData, wraps in ProfileShortcutViewModel, adds to list |
Shortcut Recording State (Code-Behind)¶
| Field | Type | Description |
|---|---|---|
_recordingShortcut |
ProfileShortcutViewModel |
Currently recording shortcut, or null |
_recordTimer |
DispatcherTimer (33 ms) |
Fires RecordTimer_Tick during recording |
_lastRecordedEntries |
TriggerButtonEntry[] |
Entries captured so far (saved on stop) |
_recordAxisBaselines |
Dictionary<Guid, int[]> |
Per-device axis snapshots at record start. Deflections exceeding AxisRecordDeltaThreshold (0.25) register as axis triggers |
RecordTimeoutSeconds |
const double (5) |
Auto-stop timeout |
ProfileSwitchOverlay¶
Files: ProfileSwitchOverlay.xaml, ProfileSwitchOverlay.xaml.cs
Win11 volume OSD–style flyout that appears above the taskbar during profile switches. Shows the profile name, then transitions through initializing/active/offline states.
Window Properties¶
Non-activating, topmost, transparent-background overlay:
- WindowStyle="None", AllowsTransparency="True", Topmost="True"
- ShowInTaskbar="False", ShowActivated="False", Focusable="False"
- WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW applied via SetWindowLong in OnLoaded
- WM_MOUSEACTIVATE intercepted to return MA_NOACTIVATE. Clicks pass through
Layout Structure¶
Grid (ClipToBounds="True") ← clips slide animation at taskbar edge
└─ Grid x:Name="FlyoutPanel" (Margin="10,10,10,14")
├─ ShadowBorder (CornerRadius=8, DropShadowEffect BlurRadius=15)
└─ ContentBorder (CornerRadius=8, Background=#2D2E2E, Border=#141516)
└─ Grid (Margin="16,13,16,13")
└─ StackPanel (Horizontal)
├─ StatusIcon (Segoe Fluent Icons, 16px)
└─ StatusText (Segoe UI Variable Text, 14px)
All color values pixel-measured from the native Win11 volume OSD at 2560x1600 / 150% DPI. Dark and light themes applied dynamically via ApplyTheme() using ApplicationThemeManager.GetAppTheme().
Slide Animation¶
FlyoutPanel.RenderTransform is a TranslateTransform. The outer Grid uses ClipToBounds="True" to hide the panel while it slides:
| Method | Direction | Duration | Easing |
|---|---|---|---|
SlideIn() |
Y: 80 → 0 | 300 ms | CubicEase (EaseOut) |
SlideOut(Action onCompleted) |
Y: 0 → 80 | 250 ms | CubicEase (EaseIn) |
SlideIn() snaps _slideTransform.Y = SlideTravel (80) synchronously, then defers the animation to DispatcherPriority.Loaded so WPF does not coalesce start and end values into a single frame.
State Machine¶
The overlay progresses through up to four phases after ShowProfileName(name) is called:
Profile Name (2s) → Initializing (polling) → Active (2s) → Offline (2s) → Hide
└─→ Hide (if no offline)
| Phase | Icon | Text | Timer |
|---|---|---|---|
| Profile | \uE8F1 (people) |
Profile name | _dismissTimer 2 s → start init monitor |
| Initializing | \uE895 (sync) |
"Initializing" | _initMonitorTimer 33 ms polling CheckInitState |
| Active | \uE73E (checkmark, accent color) |
"Forging" | _dismissTimer 2 s → check offline |
| Offline | \uE7BA (warning, #FFB900 amber) |
"One or more controllers offline" | _dismissTimer 2 s → slide out + hide |
During the Initializing phase, StatusIcon runs a DoubleAnimation opacity flash (1.0 → 0.3, 600 ms, AutoReverse, RepeatBehavior.Forever).
Public API¶
| Method / Property | Description |
|---|---|
CheckInitState |
Func<(bool anyInitializing, bool allReady)>. Set by InputService |
CheckAnyOffline |
Func<bool>. Set by InputService |
ShowProfileName(string name) |
Resets state, shows profile name, starts the state machine |
ShowVCsToggle(bool enabled) |
One-shot toast for the master virtual-controller toggle, outside the profile state machine. Checkmark \uE73E in Fluent success green #37C852 when enabled, cancel \uE7E8 in Fluent critical red #E81B1C when disabled, then a 2 s dismiss. Called from InputService.ShowVCsToggleOverlay |
StopTimers() |
Stops both _dismissTimer and _initMonitorTimer. Called during shutdown |
Positioning¶
ShowFlyout() centers the window horizontally within SystemParameters.WorkArea and positions the bottom edge at WorkArea.Bottom. The 14 px bottom margin on FlyoutPanel provides the gap above the taskbar.
AboutPage¶
Files: AboutPage.xaml, AboutPage.xaml.cs
Application identity, description, technologies, and license.
Layout Structure¶
ScrollViewer (Padding="24,0")
└─ StackPanel (Margin="0,16,0,16")
├─ Page header (E946 info icon + title)
├─ App identity card (centered, 24px padding)
│ ├─ PadForge-icon.png (120px)
│ ├─ "PadForge" (28px, Bold, Display face)
│ ├─ Subtitle (14px)
│ └─ Tagline (12px)
├─ "An Invitation" section header (About_Testimony, E734 icon)
├─ Testimony card (Scripture + reference + doxology, italic)
├─ "Overview" section header (E7C3 icon)
├─ Description card (wrapping text, line height 22)
├─ "Built With" section header (E74C checkmark icon)
├─ Technologies card (Grid, 164px label + description, 133 rows in alphabetical
│ order, case-insensitive, leading symbols ignored):
│ ├─ 3doverstock
│ ├─ AntiMicroX
│ ├─ Arcade I/O references
│ ├─ AudioEndPointLibrary
│ ├─ AutoEq
│ └─ ...128 more open-source attributions through ZstdSharp (HIDMaestro, SDL3,
│ .NET 10, HidHide, NAudio, WPF UI, the Kaldi / OpenFst / OpenBLAS /
│ CLAPACK row, and others)
├─ "License" section header (E8D7 icon)
└─ License card (12px wrapping text, secondary brush)
The rows sort by label, ignoring case and a leading symbol, so .NET 10 sits between nefcon and Nintendo_Switch_Reverse_Engineering and $Q Recognizer between psmoveapi and QMK Firmware. Grid.Row runs 0 (3doverstock) through 132 (ZstdSharp). Row 60, "Kaldi, OpenFst, OpenBLAS, and CLAPACK" (About_Kaldi / About_KaldiDesc), credits the speech decoder, transducer library, and linear algebra inside libvosk.dll. A new row takes its alphabetical place, and every Grid.Row after it moves down one.
Code-Behind¶
Constructor only. All text from localized string bindings.
Dialog Windows¶
CopyFromDialog¶
Files: CopyFromDialog.xaml, CopyFromDialog.xaml.cs
Modal dialog to copy from another slot (FluentWindow, 420x420). Lists one entry per other slot whose mapping table has rows (InputService.SlotHasAnyMapping or the donor PadSetting.HasAnyMapping), plus one entry per unmapped device that still carries a PadSetting with mappings. The target slot itself is skipped. The chosen slot's mapping table is applied wholesale to the target, moved by role where the two slots are on different wires of the same pads (InputService.LandingOf), and every assigned device's per-device tuning carries along through InputService.BuildPerDeviceSettingsSnapshot + ApplyPerDeviceSettingsToSlot, matched by InstanceGuid (perfect round-trip) or ProductGuid (same model, different unit).
ProfileDialog¶
Files: ProfileDialog.xaml, ProfileDialog.xaml.cs
Modal dialog to create/edit profiles (FluentWindow, 500x470). Fields: profile name, executable list (comma-separated).
StarterProfilesDialog¶
Files: StarterProfilesDialog.xaml, StarterProfilesDialog.xaml.cs
The bundled starter-profile browser (#256). FluentWindow (700x620, min 560x480) opened from the Profiles page "Browse Starters" button. Picking a starter materializes it as a new profile.
RegisterNfcTagDialog¶
Files: RegisterNfcTagDialog.xaml, RegisterNfcTagDialog.xaml.cs
Capture-and-name flow for NFC tags (#150). The class extends Wpf.Ui.Controls.FluentWindow (ExtendsContentIntoTitleBar, WindowBackdropType="Mica", 520x520). Opened from the Devices page for an NFC reader. Tapping a tag captures its UID, the user names it, and it is added to NfcTagRegistry, which surfaces it as a bindable named button on the NFC device. The dialog also lists registered tags with a Remove action.
While open, the dialog subscribes to two capture paths: NfcReaderService.TagDetected (from NfcReaderService.Active) for a standalone reader, and NfcTagRegistry.ControllerTagDetected for a controller-borne reader, with NfcTagRegistry.RegistrationCaptureActive raised for the dialog's lifetime. OnTagDetected fires on the reader's monitor thread and marshals to the UI thread via Dispatcher.BeginInvoke before normalizing the UID and enabling RegisterBtn. RegisterButton_Click calls NfcTagRegistry.Register(uid, name), then refreshes the list. Both subscriptions are torn down on Closed.
| Element | Binding / Handler | Purpose |
|---|---|---|
StatusText |
localized status strings | Tap prompt, captured, or no-reader message |
UidText |
captured UID (telemetry mono) | Live UID readout |
NameBox |
NameBox_KeyDown |
Tag name entry. Enter registers |
RegisterBtn |
RegisterButton_Click |
Registers the captured tag |
TagListBox |
NfcTagRegistry.Tags |
Registered tags with per-row Remove (RemoveButton_Click) |
LearnHandheldButtonDialog¶
Files: LearnHandheldButtonDialog.xaml, LearnHandheldButtonDialog.xaml.cs
Learner for a handheld PC's hidden buttons (#343), sharing the NFC dialog's head chrome. FluentWindow (ExtendsContentIntoTitleBar, WindowBackdropType="Mica", 560x640, ResizeMode="NoResize") opened from the Devices page LearnHandheldButton_Click.
A learn run is a three-phase timed pass driven by _phaseTimer over HandheldLearnSession: Idle (the noise floor), Press, then Release, each phase lasting the session's own IdleMs / PressMs / ReleaseMs. FinishLearn reads the candidates and the chord keys the session collected, and the user names the winner and registers it. There is no per-model table: the machine teaches the app what its buttons are.
| Element | Binding / Handler | Purpose |
|---|---|---|
MachineText / DaemonText |
code-behind | The detected handheld and the vendor-daemon notice |
StartBtn |
StartButton_Click |
Begins the three-phase pass. Disabled when the row has been retired mid-dialog |
StatusText |
phase strings | Which phase is running, or why none can |
ChordText / CandidateBox |
learn results | The chord the press produced, and the candidate list when the pass finds more than one |
NameBox |
NameBox_KeyDown |
Display name. Enter registers |
RegisterBtn |
RegisterButton_Click |
Adds the learned button to HandheldButtonRegistry |
ButtonListBox / EmptyText |
RefreshList |
Learned buttons with per-row Remove (RemoveButton_Click), and the empty-state line |
| Export / Import | ExportButton_Click / ImportButton_Click |
Carries a learned set between machines |
RegisterVoicePhraseDialog¶
Files: RegisterVoicePhraseDialog.xaml, RegisterVoicePhraseDialog.xaml.cs
Voice macro management (#317), modeled on RegisterNfcTagDialog and sharing its head chrome. FluentWindow (ExtendsContentIntoTitleBar, WindowBackdropType="Mica", 640x620, ResizeMode="NoResize") opened from the Devices page ManageVoicePhrases_Click. Three bands: the settings row (EnabledBox, ModeBox, ConfidenceSlider clamped 0.5 to 0.99 with a ConfidenceText readout), the live HeardText readout prefixed with the microphone that heard the phrase, and the type-and-name registration row with a phrase list carrying per-row Remove.
There is no source picker. Phrases live on the devices that carry the microphones, and every reachable microphone runs its own session.
The dialog subscribes to the static VoiceMacroService.PhraseHeard event, hops to the UI thread with Dispatcher.BeginInvoke, and lights the matching PhraseRow for 1400 ms. Only a firing recognition lights a row: the engine maps every utterance to its nearest phrase, so lighting on any event made rows claim matches the confidence floor had already refused. The readout line still shows every attempt with its confidence. Each row owns its own FlashTimer, restarted per hit so overlapping recognitions extend the light instead of truncating it. Closed and the close button both run Unsubscribe, which stops every row timer and detaches the handler.
| Element | Binding / Handler | Purpose |
|---|---|---|
EnabledBox / ModeBox / ConfidenceSlider |
Setting_Changed |
Writes VoiceMacroService.Enabled / ListeningMode / MinConfidence, then marks settings dirty |
HeardText |
VoiceMacroService.PhraseHeard |
[source] text (confidence), fired or ignored |
PhraseBox / NameBox |
PhraseBox_KeyDown |
Phrase and display name. Enter registers |
RegisterBtn |
RegisterButton_Click |
VoicePhraseRegistry.Register(phrase, name), then refreshes |
PhraseListBox |
_rows (PhraseRow) |
Registered phrases with per-row Remove (RemoveButton_Click) |
ConfirmDialog¶
Files: ConfirmDialog.xaml, ConfirmDialog.xaml.cs
Generic modal confirm. FluentWindow with the ember-tick display-face header, a message body, and OK / Cancel buttons. Used ahead of destructive actions across the app.
ManageProfilesDialog¶
Files: ManageProfilesDialog.xaml, ManageProfilesDialog.xaml.cs
HIDMaestro profile catalog manager. FluentWindow (640x660). Section 1 lists imported profiles (name + id) with Import From File / Export / Delete (Delete wears the destructive chrome and confirms first). A row HIDMaestro left out of the pickers carries a third line in SystemFillColorCautionBrush, ImportedProfileRow.Problem: HIDMaestro's reason and JSON path for a refused file, or the missing HID descriptor. Section 2 imports a profile from a connected device. An import HIDMaestro would refuse is not stored, and the status line gives the reason (ImportFailedText).
UninstallHIDMaestroDialog¶
Files: UninstallHIDMaestroDialog.xaml, UninstallHIDMaestroDialog.xaml.cs
The removal of HIDMaestro from the PC (#518), in ConfirmDialog's chrome: the ember-tick title, the body, and a footer where Cancel holds the default and the initial focus. Confirm(owner, preview, out includeUsbip) runs the preview it is handed (HIDMaestroRemoval.Preview on a worker) on load and on each change of the usbip-win2 checkbox, showing a progress line meanwhile, then lists each item as location, kind and, for one kept or not read, the reason (RowsFor). A blocked preview names the programs in the cold mono face, and a preview with nothing to take says so. Refresh reads the PC again. Uninstall opens only when CanRemove holds. ShowReport(owner, report, statusLine) reuses the window with Close alone to list what a failed removal could not take. A preview that ends after the window closed, or after a newer one started, is dropped (_generation).
UninstallBthPS3Dialog¶
Files: UninstallBthPS3Dialog.xaml, UninstallBthPS3Dialog.xaml.cs
The removal of BthPS3 from the PC, built as UninstallHIDMaestroDialog is, without its usbip-win2 option. Confirm(owner, preview) runs the preview it is handed (BthPS3Removal.Preview on a worker) on load and on Refresh, then lists each item as location, kind (BthPS3Removal.KindText) and, for one kept, the reason in the HIDMaestro removal's Stays: form (RowsFor). The line above the list (NoteFor) says what holds the removal (a connected controller, or a check that could not run), that DsHidMini keeps the drivers, or that nothing is left. Uninstall opens only when CanRemove holds. ShowReport(owner, report, statusLine) reuses the window with Close alone to list what a removal could not take.
PairDeviceDialog¶
Files: PairDeviceDialog.xaml, PairDeviceDialog.xaml.cs
Wii Remote pairing flow (#116). FluentWindow (WiiPair_Title) that walks the user through the Bluetooth sync-button handshake.
PickSoundDialog¶
Files: PickSoundDialog.xaml, PickSoundDialog.xaml.cs
Sound file chooser for macro sound actions and sound packages. FluentWindow (460x480).
ColorPickerControl¶
Files: ColorPickerControl.xaml, ColorPickerControl.xaml.cs
HSV color picker UserControl. A 200x200 saturation/value square over a hue base layer, plus a hue strip. Feeds the lightbar and RGB color rows. The hue base layer is rebuilt from code-behind on hue change.
ShiftActivatorDialog¶
Files: ShiftActivatorDialog.xaml, ShiftActivatorDialog.xaml.cs
Shift-layer editor (Pad_Shift_DialogTitle, 600x760). Records the activator combo and configures the layer, including RGB color rows cloned from the Lighting tab's chrome. The square icon button left of the name box shows the layer's icon, a picture (IconPickerImage, 22x22) when the reference resolves and the glyph (IconPickerGlyph) otherwise, both through MenuIconResolver.ResolveLayerIcon. It opens the shared IconPicker (#471) with Reset to Default (⇧) as its clear button. The emoji catalog (EmojiCatalog, EmojiCategory) stays in this file and the picker reads it.
ShiftLayerFlyout¶
Files: ShiftLayerFlyout.xaml, ShiftLayerFlyout.xaml.cs
Win11 volume-OSD-style flyout that reuses ProfileSwitchOverlay's pixel-measured chrome (bg #2D2E2E, border #141516, corner radius 8, layered shadow border plus content border so ClearType survives). Stays visible while a shift layer is engaged on the currently-viewed slot, slides out when the slot returns to Base. ShowLayer draws the layer's icon through MenuIconResolver.ResolveLayerIcon: a picture reference that resolves fills StatusImage (20x20, uniform, in the glyph's spot, #471), a single glyph such as an emoji shows as text in StatusIcon, and anything else, an empty reference included, shows ⇧.
TouchpadGestureRecorderDialog¶
Files: TouchpadGestureRecorderDialog.xaml, TouchpadGestureRecorderDialog.xaml.cs
Touchpad gesture recorder (#88). FluentWindow (640x660) that captures a stroke and saves it as a named gesture template. Inherits theme from Application.Resources (no local ThemesDictionary) so it tracks the user's light/dark choice.
TouchpadOverlay¶
Files: TouchpadOverlay.xaml, TouchpadOverlay.xaml.cs
Transparent, topmost Window that mirrors the touchpad surface with live finger dots and a fixed 32px click bar in the bottom row. Repositionable via the surface drag region, stylus press-and-hold / feedback suppressed.
MenuOverlayWindow¶
Files: MenuOverlayWindow.xaml, MenuOverlayWindow.xaml.cs
On-screen radial / touch menu HUD (#9). A click-through, never-activated Window (WindowStyle="None", AllowsTransparency, Topmost, ShowInTaskbar="False", ShowActivated="False", SizeToContent="WidthAndHeight") following the ShiftLayerFlyout precedent: WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TRANSPARENT applied on Loaded, plus a WndProc hook answering WM_MOUSEACTIVATE with MA_NOACTIVATE, so it never steals focus from the game and never eats a click.
Owned by InputService, which pulls InputManager.ActiveMenuOverlay on the ~30 Hz UI timer (created lazily on first engage, hidden when no menu is engaged or the Dashboard's EnableMenuOverlay is off) and calls UpdateFromSnapshot. Content is built in code on a bare Canvas: annular wedge Path geometries for radial rings (a single slot renders as the full donut), rounded Rectangle cells for grids. Geometry rebuilds only when the menu identity or its GeometrySig (kind, cell count, center, labels, scale, position, opacity, name, item labels) changes. The per-tick work is the hover restyle: the hovered cell fills with SystemAccentColorPrimaryBrush when resolvable, ember orange otherwise. MaxRenderCells (64) caps the visual build. A hand-hacked config past that still hovers and commits, the window just refuses to build an unbounded visual. The window is centered at the menu's PosXPercent / PosYPercent on the primary work area (50/50 = centered), clamped fully on screen. Theme-aware through dark / light brush pairs re-applied on every rebuild.
IconPicker¶
Files: IconPicker.xaml, IconPicker.xaml.cs
The shared icon picker (#471), a UserControl shown in a Popup below the button that opened it (PlacementMode.Bottom, StaysOpen="False", so a click elsewhere closes it). Menu cell rows and the shift-layer dialog both open it through IconPickerHost.Open(anchor, currentIcon, clearLabel, apply, showPackage). Two tabs, both drawing every choice as itself:
- Emoji (
IconPicker_Emoji): a category bar, whose tooltips and accessible names are the localizedEmoji_Category_*strings throughEmojiCategory.DisplayName, and a grid overShiftActivatorDialog.EmojiCatalog. - Images (
IconPicker_Images): every registered icon package's entries as 40x40 image buttons with their file names as accessible names, grouped by package (IconGroup/IconChoice) under a trimmed header,IconPicker_NoPackageswhen no package is registered, and Browse Files… at its foot. Thumbnails decode into an 80 by 80 box (BoundedBitmap) from one read of each archive (IconPackageManager.ReadIcons), an entry at a time, so only one entry's bytes are held. A package gives the picker its first 2,048 image entries (MaxPickerIcons) and at most 128 MB of entry bytes (MaxPickerBytes, counting entries left out for size), and the picker holds at most 4,096 thumbnails across every package (IconPicker.MaxThumbnails), since a profile import registers every package it carries. The read that reaches that bound stops there, and no later package is read until the registry changes. The picker keeps the name of each package it has read, so the next opening reads none of them again, and an entry past a bound or one that does not decode is simply absent. A package it could not read, missing or locked by another program, is not kept, so the next opening tries it again. Thumbnails are kept apart from the resolver's full-size images and dropped when the registry changes.
The choice the caller holds now wears the active tab's ember (IsCurrent), and the picker opens on its tab and its emoji category. The clear button at the bottom takes the caller's label: No Icon for a cell, Reset to Default (⇧) for a layer. On open, keyboard focus goes to the open tab, since WPF moves none into a popup. Tab cycles inside the picker, and Escape closes it. A pick raises IconChosen (an empty string clears) and closes the popup, and a pick or Escape returns focus to the button that opened it. Browse Files… closes it and runs IconPickerHost.BrowseFromDisk, which offers images the resolver draws (PNG, JPEG, GIF, BMP) and .pficons packages: an image file stores through IconPickerHost.StoredImagePath, exe-relative under the app folder and .\name for a file directly beside the exe, a .pficons file is registered, a one-entry package is chosen at once, and a package with several entries reopens the picker on that package's pictures.
RemoteLinkPairDialog¶
Files: RemoteLinkPairDialog.xaml, RemoteLinkPairDialog.xaml.cs
Remote Link consent screen (#138). FluentWindow (460 wide) that shows the short authentication string (SasText, compared out of band against the peer screen), the peer IdentityText, a consent-warning InfoBar, and a GamepadOnlyCheck checkbox. Pair accepts. Reject, or closing the window, declines.
RemoteLinkPasswordDialog¶
Files: RemoteLinkPasswordDialog.xaml, RemoteLinkPasswordDialog.xaml.cs
Remote Link password prompt (#138). FluentWindow (440 wide) with two PasswordBox fields (entry + confirm) and an error InfoBar. OK submits, Enter in either box submits too.
RemoteAssignmentsDialog¶
Files: RemoteAssignmentsDialog.xaml, RemoteAssignmentsDialog.xaml.cs
Remote device assignment (#138). FluentWindow (Mica, 620x500, min 480x360) titled RemoteLink_AssignmentTitle_Format with the peer's name, opened by InputService from a paired PC's Assign Shared Devices button. DevicesBox lists the devices this PC shares with that peer. Picking one queries the other PC through LinkAssignmentChannel.QueryAsync, and SlotsList shows its virtual controllers as check boxes, with ProfileText naming the other PC's active profile. Checking or clearing a slot sends SetAsync with the last reply's revision, so a change made on the other PC in between comes back as Stale for review. StatusText carries the busy, denied (the other PC has not allowed remote assignment changes for this PC), timeout, and no-slot messages. RefreshButton reloads the device list and the query.
WorkshopBrowseDialog¶
Files: WorkshopBrowseDialog.xaml, WorkshopBrowseDialog.xaml.cs
The Steam Workshop config browser (#9). FluentWindow (Mica, 1280x760, min 1080x640) with a three-state flow: cold-forge opt-in panel, game-search shelf, game room (config cards plus the translation manifest pane). Search debounces 500 ms. Preset chips re-run the translation live. Art crossfades 240 ms through steel and honors the Windows animation setting. On Save it hands the materialized profile to MainWindow's AddWorkshopProfile import sink. The config card's pad art is WorkshopControllerPreview (Views/WorkshopControllerPreview.xaml), a UserControl wrapping a Viewbox over a code-built Canvas so callouts stay in the art's own pixel space, which is what ControllerOverlayLayout's coordinates are expressed in. Full anatomy on Steam Workshop Config Import Internals.
CustomTelemetryFormatsDialog¶
Files: CustomTelemetryFormatsDialog.xaml, CustomTelemetryFormatsDialog.xaml.cs
The custom telemetry formats (#531), opened by the Dashboard's Custom Formats… button. FluentWindow (Mica, 1040x680, not resizable, dragged by its inert chrome since it has no title bar) titled Telemetry_Formats_Title. FormatList on the left lists the formats with Add, Duplicate and Delete buttons. EditorPanel on the right edits the selected one: its name, its packets (binary or JSON), the length and byte order for binary packets, the Required Key a JSON packet must carry, the optional gear values, and its Fields rows (name, target, offset or key, type, scale, min, max), with Add Field and a remove button per row. The dialog edits copies (TelemetryFormatEdit), so Cancel keeps the saved list. Save first pushes a focused text box's value, then shows the first problem in ProblemText and stays open, or sets Result and closes. A duplicate name is a problem, since the picker and the profiles name a format by it. New and duplicated formats take the first free name from UniqueName. Detail on Game Telemetry Internals.
Value Converters¶
All converters in PadForge.App/Converter/ (PadForge.Converters namespace). All but one are registered as StaticResource in App.xaml (lines 1057-1076). UppercaseConverter (key UpperConverter) is registered in ControllerIcons.xaml instead.
| Converter | Key | Input | Output | Description |
|---|---|---|---|---|
DzShapeNameConverter |
DzShapeNameConverter |
int (shape index) |
string |
Deadzone shape index to localized display name for the Sticks header subtitle. |
EqBandTypeNameConverter |
EqBandTypeNameConverter |
EqBandType |
string |
Localized EQ band-type name for the Audio tab band picker. Defaults to Peak, the enum's zero (#347). |
BoolToVisibilityConverter |
BoolToVisibilityConverter |
bool |
Visibility |
true = Visible, false = Collapsed. Parameter "Invert" reverses. Supports ConvertBack. |
HexToBrushConverter |
HexToBrushConverter |
string (ARGB/RGB hex) |
SolidColorBrush |
Parses #FF8E44AD / #8E44AD to a brush. Empty/invalid falls back to muted gray #555555 (frozen). Used by the shift-layer tab colored dots. |
NormToCanvasConverter |
NormToCanvasConverter |
double (0-1) |
double |
Canvas position, dot-centered. Parameter: "canvasDim" or "canvasDim,dotSize" (default dot 14). |
SignedNormToCanvasConverter |
SignedNormToCanvasConverter |
double (-1..+1) |
double |
Signed canvas position: -1 = left/top edge, 0 = center, +1 = right/bottom. For the Constant Force grid. |
PercentToSizeConverter |
PercentToSizeConverter |
int/double (0-100) |
double |
Percentage to pixel size. Parameter = max (sign preserved for offsets). For the deadzone ring. |
CrossGeometryConverter |
CrossGeometryConverter |
MultiBinding(DeadZoneX, DeadZoneY) |
Geometry |
Cross-shaped path for the axial deadzone overlay. |
SlopedWedgeGeometryConverter |
SlopedWedgeGeometryConverter |
MultiBinding(DeadZoneX, DeadZoneY) |
Geometry |
Triangular wedge paths for the sloped deadzone overlay. |
NullToCollapsedConverter |
NullToCollapsedConverter |
object |
Visibility |
Non-null = Visible, null = Collapsed. |
StringToVisibilityConverter |
StringToVisibility |
string |
Visibility |
Non-null/non-empty = Visible, else Collapsed. |
EnumIndexConverter |
EnumIndexConverter |
enum |
int |
Bridges an enum property and ComboBox.SelectedIndex (enum values assumed sequential 0..N). Supports ConvertBack. |
EnumEqualityVisibilityConverter |
EnumEqualityVisibilityConverter |
enum |
Visibility |
Visible when the enum name matches the parameter (case-insensitive, pipe-separated list allowed). Switches per-mode UI sections. |
BoolToTriggerShapeKindConverter |
BoolToTriggerShapeKindConverter |
bool (IconRightSide) |
LabeledShapeKind |
false = TriggerLeft, true = TriggerRight. |
RgbToBrushConverter |
RgbToBrushConverter |
MultiBinding(R, G, B) bytes |
SolidColorBrush |
Lightbar preview swatch on the Lighting tab. Falls back to black. |
IndexToLetterConverter |
IndexToLetterConverter |
int (0-25) |
string |
0..25 maps to a..z. Index >= 26 falls back to s[N]. Macro formula chips. |
IndexLessThanVisibilityConverter |
IndexLessThanVisibilityConverter |
int (count), param int (index) |
Visibility |
Visible when param < count. Reveals a per-letter formula chip when its index is within the variable count. |
OneBasedIndexConverter |
OneBasedIndexConverter |
int (0-based) |
string |
Renders a 0-based index as its 1-based number. Touchpad-tab pad selector. |
ExeIconConverter |
ExeIconConverter |
string (exe path) |
ImageSource |
Extracts the exe's shell icon for profile cards (cached per path, frozen). Returns null when the path is empty/missing. |
CultureLanguageConverter |
CultureLanguageConverter |
CultureInfo |
XmlLanguage |
The language a row of the language picker declares: the culture's own for Chinese, Japanese and Korean, en-US for every other (UiLanguage.Declared). |
UppercaseConverter |
UpperConverter |
string |
string |
Uppercases header strings for the ember eyebrow treatment. Registered in ControllerIcons.xaml, not App.xaml. |
Resource Dictionaries and Theming¶
App.xaml Resources¶
The #175 ember restyle grew App.xaml well past the old two-dictionary shell. Merged dictionaries (in order):
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark"/> <!-- WPF UI theme, design-time default -->
<ui:ControlsDictionary/> <!-- WPF UI control styles -->
<ResourceDictionary> <!-- font families (#175 typography) -->
<FontFamily x:Key="TelemetryFontFamily">Cascadia Code, Consolas, Segoe UI</FontFamily>
<FontFamily x:Key="BodyFontFamily">Segoe UI Variable Text, Segoe UI</FontFamily>
<FontFamily x:Key="DisplayFontFamily">Segoe UI Variable Display, Segoe UI</FontFamily>
</ResourceDictionary>
<ResourceDictionary Source="/Resources/ControllerIcons.xaml"/>
<ResourceDictionary> <!-- ember identity tokens (#175) -->
<!-- Ember*/Cold*/Steel* brushes, WaitBrush, EmberSegGradient,
FlameOuterGeometry, LivenessFlameBase, MiniTypeButton styles -->
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
<!-- App-level styles (all in the root dictionary): -->
<!-- implicit ComboBox / Button / CheckBox / TextBox / uictrl:TextBox /
ToolTip / uictrl:FluentWindow / uictrl:DynamicScrollBar restyles -->
<!-- keyed: EmberIconButton(+Hot), EmberAccentButton, EmberPrimaryButton,
EmberDestructiveButton, EmberSelectListItem, EmberSlider,
InstrumentBarRaw / InstrumentBarOut, EmberFocusVisual, EntranceFade -->
<!-- 20 global converter registrations (lines 1057-1076) -->
</ResourceDictionary>
</Application.Resources>
Ember identity tokens (ColdBrush, EmberBrush, SteelGroundBrush, CrucibleCardBrush, and their relatives) carry the #175 color language: cold = the physical side (devices, sources, telemetry), ember = the virtual side (outputs, anything live), steel = the ground the two sit on. Green/gold/red stay health-only (WaitBrush is the only sanctioned gold). EmberThemeProbe / EmberTheme.ApplyAccent swap the theme-scoped pairs on a light/dark flip.
Theme is applied at runtime via Wpf.Ui.Appearance.ApplicationThemeManager.Apply(...) (Light / Dark) or ApplySystemTheme(). The Theme="Dark" attribute on ThemesDictionary is the design-time default.
App-level control styles (#175):
| Key (or implicit target) | What it does |
|---|---|
implicit ComboBox / Button / CheckBox / TextBox / uictrl:TextBox |
30-32px grid metrics, ember text selection, faint neutral hover glow |
implicit ToolTip / uictrl:FluentWindow |
Body font, left-aligned tooltip text, dialog font inheritance |
implicit uictrl:DynamicScrollBar |
Always-visible track + thumb (stock template fades them in on hover) |
EmberIconButton / EmberIconButtonHot |
28x28 icon micro-buttons, cold ring for input actions, ember ring for output-writing actions |
EmberAccentButton / EmberPrimaryButton |
Ember rim + text affirmative verb (plain Button / uictrl:Button) |
EmberDestructiveButton |
Ember-deep rim + text for destructive confirms |
EmberSelectListItem |
Retemplated ListBoxItem: ember-tint wash + #66FF6B2C rim on select |
EmberSlider |
Retemplated single-thumb slider with an ember decrease-side fill |
InstrumentBarRaw / InstrumentBarOut |
RAW (cold-deep flat) / OUT (ember gradient + glow) telemetry ProgressBar retemplates |
EmberFocusVisual (+ DefaultControlFocusVisualStyle, FocusVisualStyleKey) |
Ember keyboard-focus ring app-wide |
EntranceFade |
150ms fade + 6px settle on Loaded for page/card roots |
ControllerIcons.xaml¶
File: PadForge.App/Resources/ControllerIcons.xaml
DrawingImage icons (sidebar, dashboard, profiles):
| Key | Source | Description |
|---|---|---|
XboxControllerIcon |
svgrepo.com (32x32) | Xbox logo icon |
DS4ControllerIcon |
svgrepo.com (32x32) | PlayStation logo icon |
NintendoControllerIcon |
Switch logo (32x32 box) | Nintendo family icon |
ExtendedControllerIcon |
svgrepo.com, viewBox -4 -2 24 24 | Joystick icon |
GenericControllerIcon |
svgrepo.com (512x512, scaled) | Generic gamepad with D-pad and face buttons |
KeyboardMouseControllerIcon |
placeholder path | Placeholder so resource lookup succeeds. KB+M actually renders via an MDL2 glyph in code-behind. There is no MidiControllerIcon. MIDI uses the E8D6 MDL2 glyph directly. |
Fills use DynamicResource TextFillColorPrimaryBrush. The GenericControllerIcon cutouts (D-pad, face buttons) use SolidBackgroundFillColorSecondaryBrush.
Hover-glow effects (frozen DropShadowEffect, shared by every hover trigger and code-behind site): EmberHoverGlow, EmberHoverGlowSmall, ColdHoverGlow, NeutralHoverGlow. Static Effect setters only, never animated (Effect animation from style triggers crashes at startup).
PadPage tab-icon geometries (Sticks/Triggers tab headers, derived from Zacksly Xbox Series icons, CC BY 3.0): ZackslyStickOuterRingGeometry, ZackslyStickInnerDiscGeometry, ZackslyTriggerOuterBodyGeometry, ZackslyTriggerOuterBodyRightGeometry, plus TouchpadStickIconGeometry (Segoe Fluent Icons F108 silhouette).
Shared card and section styles:
| Key | TargetType | Properties |
|---|---|---|
CardBorder |
Border |
CardBackgroundFillColorDefaultBrush background, 8px corner radius, 16px padding, 12px bottom margin, 1px #253049 stroke that heats to steel-lift on hover (theme-scoped via EmberThemeProbe) |
CardTitle |
TextBlock |
15px Display face, SemiBold |
CardSectionTitle |
TextBlock |
CardTitle + 8px bottom margin (divider-separated section title inside a card) |
CardSubsectionTitle |
TextBlock |
SemiBold at body size, 6px bottom margin (one rank below CardTitle) |
CardDescription |
TextBlock |
12px Body face, TextFillColorSecondaryBrush, wrap enabled, 8px bottom margin |
SectionGlyph |
TextBlock |
Ember MDL2 glyph, 13px, for page section headers |
SectionTitle |
TextBlock |
10px Telemetry mono eyebrow, SemiBold, tertiary |
ChipCold / ChipEmber / ChipGhost |
Border |
Artifact chips: cold = physical fact, ember = virtual fact, ghost = neutral |
Theme Switching¶
SettingsViewModel.SelectedThemeIndex maps to:
- 0 = System Default (follows Windows setting)
- 1 = Light
- 2 = Dark
Applied in MainWindow.OnThemeChanged via Wpf.Ui.Appearance.ApplicationThemeManager.Apply(ApplicationTheme.Light|Dark) or ApplySystemTheme(), followed by EmberTheme.ApplyAccent() and UpdateSteelLayer(). The KBM, MIDI, and mouse previews compare ApplicationThemeManager.GetAppTheme() on each rendered frame and rebuild their visuals when it changed. Only the Add Controller popup and the profile switcher popup subscribe to ApplicationThemeManager.Changed, to repaint their backgrounds while open.
Ember Identity Brushes¶
Defined inline in the App.xaml ember-tokens dictionary. Values below are the dark defaults. EmberTheme.ApplyAccent swaps the theme-paired ones on a light/dark flip.
| Brush Key | Color | Usage |
|---|---|---|
EmberBrush / EmberHotBrush / EmberDeepBrush |
#FF6B2C / #FFA24D / #C43D0C |
Virtual side: outputs, live state, accent verbs |
ColdBrush / ColdDeepBrush / ColdMutedBrush |
#58B6E4 / #2E6A8F / #9E58B6E4 |
Physical side: devices, sources, telemetry |
SteelGroundBrush / SteelRaisedBrush / SteelLineBrush / SteelLineSoftBrush |
#0B0E14 / #1B2333 / #253049 / #1C2536 |
Ground, raised, hairline, soft hairline |
CrucibleCardBrush |
#111623 gradient |
Slot-card ground. EmberTheme swaps it per theme, and light falls back to the card fill. There is no SteelCardBrush key |
EmberTintBrush / ColdTintBrush |
#1AFF6B2C / #1A58B6E4 |
Checked-tab and chip washes |
EmberTextBrush |
#FF6B2C |
Raw-ember text that deepens on white. Theme-paired |
WaitBrush |
#E8B434 |
The only sanctioned gold: cooling flames, awaiting-devices |
The RangeSliderThumbFill brush from the v3 layout no longer exists.
Common XAML Patterns¶
Card-Based Layout¶
Settings and driver pages use this card pattern:
<Border Style="{StaticResource CardBorder}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="16"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="Card Title" Style="{StaticResource CardTitle}"/>
</StackPanel>
<TextBlock Text="Description text." Style="{StaticResource CardDescription}"/>
<!-- Card content -->
</StackPanel>
</Border>
Liveness Flame Indicator¶
Online/offline state renders as a flame Path, not a colored dot. The LivenessFlameBase style (App.xaml) draws an outline-only flame at rest. Each site adds one DataTrigger on IsOnline that fills it ember with a glow:
<Path>
<Path.Style>
<Style TargetType="Path" BasedOn="{StaticResource LivenessFlameBase}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsOnline}" Value="True">
<Setter Property="Fill" Value="{DynamicResource EmberBrush}"/>
<Setter Property="Stroke" Value="{x:Null}"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect Color="#FF6B2C" BlurRadius="8" ShadowDepth="0" Opacity="0.5"/>
</Setter.Value>
</Setter>
</DataTrigger>
</Style.Triggers>
</Style>
</Path.Style>
</Path>
The engine-state indicator in the status bar uses the same flame with DataTriggers on EngineStateKey instead (ember when Running, gold when Idle/Stopping).
Segoe MDL2 Asset Icons¶
Segoe MDL2 Assets font glyphs instead of image resources:
Codes used: E713 settings, E790 personalization, E9F5 processing, E737 star, ED1A shield, E7FC gamepad, E8A5 save, E9D9 bug, E8F1 group, E8B7 library, E8B9 photo, F158 3D, E946 info, E772 devices, E7E8 power, E740 full screen, E710 add, E711 close, E72A forward, E72C undo, E700 global nav (hamburger), E8D6 music, EC4F MIDI, E961 keyboard, E774 globe, EDA4 touchpad, F119 VR headset, F2B7 language, E8C8 copy, E8D7 document, E7C3 page, E74C checkmark, E71B link, E75C clear, E74D delete, F404 home, E7BA warning, and the section and card glyphs named on this page: E702 Pair, E707 Compass Yaw, E716 Community Configs, E767 volume, E77B Head Tracking, E781 Lighting and the Lighting vendor rows, E7F8 Handheld PC Buttons, E815 Grip, E83F Battery Alerts, E877 Force Feedback, the rumble chip and the Haptics vendor row, E8DE Assignment Prompts, E969 Remote Link, E99A Tilt, E9E9 Gyro Sensitivity, EB9F Icon Packages, EFA5 touchpad chip, F0AD Constant Force, F1CB Gyro Response, F272 Gyro Calibration. EC05 (broadcast) and ED5D (driver) left with the Dashboard's driver status strip and appear nowhere in the app.
WPF UI NumberBox¶
Numeric input with inline spin buttons:
<ui:NumberBox Value="{Binding PollingRateMs, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Minimum="1" Maximum="16"
SpinButtonPlacementMode="Inline" Width="200"/>
Every NumberBox binds with UpdateSourceTrigger=PropertyChanged. WPF UI registers Value to write back on LostFocus, and the spin buttons step the value without focusing the box, so a box bound without the trigger saved nothing from its arrows until it was clicked into and out of again (#511). The control sets Value only on Enter, on leaving the box, on a paste and on a step, so typing still saves once. A box bound to a port adds Delay=1000, because a port change restarts its server: a held arrow then restarts it once, a second after the last step. NumberBoxCommitTests holds every box in the app to both rules.
Localized String Bindings¶
All user-facing text uses localized bindings via Strings.Instance:
Enables runtime language switching without restart.
MarqueeBehavior for Overflow Text¶
Long text (device names, GUIDs, paths) uses MarqueeBehavior.IsEnabled="True" inside a ClipToBounds="True" Border for scrolling overflow.
Code-Behind Patterns¶
Bind/Unbind Pattern¶
All six visualization views (3D, 2D, Schematic, MIDI, KBM, VR) share this interface:
public void Bind(PadViewModel vm) // Subscribe to PropertyChanged, hook rendering, load model
public void Unbind() // Stop flash, unhook rendering, clear VM reference
Lets PadPage switch views cleanly when output type or preference changes.
CompositionTarget.Rendering with Dirty Flag¶
Per-frame visual updates via CompositionTarget.Rendering, gated by a _dirty flag:
private bool _dirty;
private void OnRendering(object sender, EventArgs e)
{
if (!_dirty || _vm == null) return;
_dirty = false;
// Update visuals...
}
private void OnVmPropertyChanged(object sender, PropertyChangedEventArgs e)
{
_dirty = true; // Coalesce multiple property changes into one render frame
}
Batches multiple property changes into one visual update per frame, avoiding redundant work.
DispatcherTimer Flash Animation¶
"Map All" recording flow across all views:
private DispatcherTimer _flashTimer;
private string _flashTarget;
private bool _flashOn;
private void UpdateFlashTarget(string target)
{
// Start or stop flash timer based on target
// Timer callback toggles highlight/default materials at 400ms interval
}
The 3D, 2D, schematic, KBM, and MIDI views tick at 400 ms. The VR preview ticks at 450 ms.
Event Relay Pattern¶
Code-behind raises events that MainWindow.xaml.cs wires to services, keeping views decoupled:
// DashboardPage.xaml.cs
public event EventHandler<int> DeleteSlotRequested;
private void DeleteSlot_Click(object sender, RoutedEventArgs e)
{
if (sender is Button btn && btn.Tag is int slotIndex)
DeleteSlotRequested?.Invoke(this, slotIndex);
}
// MainWindow.xaml.cs (wiring, abridged)
DashboardPageView.DeleteSlotRequested += (s, slotIndex) =>
Dispatcher.BeginInvoke(new Action(() =>
{
bool hadActiveVc = _inputService.IsHmVcAt(slotIndex);
var info = _deviceService.DeleteSlot(slotIndex);
_inputService.OnSlotDeleted(slotIndex, info.Type, info.OldGroupPosition,
deletedSlotHadActiveVc: hadActiveVc);
}));
Syncing Guard Pattern¶
Prevents recursive updates when programmatically setting control values:
private bool _syncingExtendedConfig;
private void SyncExtendedConfigBar()
{
if (DataContext is not PadViewModel vm) return;
bool isExtended = vm.OutputType == Engine.VirtualControllerType.Extended;
bool isKbm = vm.OutputType == Engine.VirtualControllerType.KeyboardMouse;
HMaestroProfileBar.Visibility = ((vm.HasHMaestroProfileBar && !isExtended) || isKbm)
? Visibility.Visible
: Visibility.Collapsed;
HMaestroProfileCombo.Visibility = isKbm ? Visibility.Collapsed : Visibility.Visible;
KbmSurfacesCombo.Visibility = isKbm ? Visibility.Visible : Visibility.Collapsed;
ExtendedConfigBar.Visibility = isExtended ? Visibility.Visible : Visibility.Collapsed;
if (isExtended)
{
_syncingExtendedConfig = true;
SyncExtendedFields(vm); // populates profile combo + Customize-gated overrides
_syncingExtendedConfig = false;
}
}
private void ExtendedCustomize_Toggled(object sender, RoutedEventArgs e)
{
if (_syncingExtendedConfig) return; // Skip when syncing programmatically
if (DataContext is not PadViewModel vm || vm.ExtendedConfig == null) return;
vm.ExtendedConfig.Customize = ExtendedCustomizeChk.IsChecked == true;
}
See Also¶
- Architecture Overview: Application shell, page hosting, WPF UI theme
- ViewModels:
PadViewModel,DashboardViewModel,DevicesViewModel,SettingsViewModel - Services Layer:
InputService,SettingsService,DeviceServicewired inMainWindow.xaml.cs - 2D Overlay System:
ControllerModel2DView,ControllerSchematicView,KBMPreviewView,MidiPreviewView,VRPreviewView - 3D Model System:
ControllerModelView(HelixToolkit 3D viewport) - Settings and Serialization:
PadSettingdescriptors driving mapping grid UI - Peripheral Outputs Internals: The links, claims and backend states the Lighting and Force Feedback route lines read
- Virtual Controllers: Output type selection UI for Xbox, PlayStation, Nintendo, Extended, KB+M, MIDI, VR (all HM-backed types are produced by
HMaestroVirtualController, VR byHMaestroVRController). The Add Controller popup builds a Nintendo button (Switch logo, AutomationIdAddNintendoBtn, capacity viaMaxNintendoSlots) between PlayStation and Extended, and a VR button (F119glyph, AutomationIdAddVrBtn, capacity viaMaxVrSlots= 1) at the tail, theVirtualControllerGroups.InOrdervisual order. - Driver Installation Internals: HidHide install/uninstall and the Windows MIDI Services runtime's install and uninstall, triggered from
SettingsPage(HIDMaestro is embedded. OpenXInput'sxinput1_4.dllunpacks into the single-file extraction directory under%TEMP%\.net\PadForge, whichApp.OnStartupadds to the DLL search path)
Last updated for PadForge 5.2.0.