HandyControl pitfalls: custom title bar + tab strip in title bar
Recorded 2026-06-03, while implementing a Windows-Terminal-style UI in the ClassicTerminal project. Environment: HandyControl 3.5.1, .NET 6 WPF, Windows 10 LTSC 2019 (build 17763). HC source code:
C:\Users\Leyu_Tester\Desktop\HandyControlResult:ClassicTerminal/MainWindow.xaml(commitc8dcb8e, dev branch)
Pitfall 1: hc:Window custom title bar requires an explicit WindowWin10 style
hc:Window provides NonClientAreaContent for embedding custom content into the title bar, but there are two hard requirements. Missing either one silently falls back to the default Windows title bar (no error, no warning):
<!-- ✅ correct -->
<hc:Window Style="{StaticResource WindowWin10}"
ShowTitle="False" ShowIcon="False"
NonClientAreaHeight="36"
NonClientAreaBackground="#FF202020"
NonClientAreaForeground="#FFAFAFAF">
<hc:Window.NonClientAreaContent>
<Grid Height="36">...</Grid>
</hc:Window.NonClientAreaContent>
</hc:Window>
- You must write
Style="{StaticResource WindowWin10}"explicitly — HC's automatic style application in its static constructor (StyleProperty.OverrideMetadata+ theResourceHelperstatic cache) can fail in a real project. - You must not set the title-bar button color properties as local values on the
<hc:Window>element:OtherButtonBackground/Foreground/Hover*,CloseButtonBackground/Foreground/Hover*. Local values take precedence over the Style and interfere with the trigger bindings insideWindowWin10Template, causing the whole template to fail to render. To change button colors, override via a Style instead (BasedOn="{StaticResource WindowWin10}").
For the detailed investigation, see the AI_Inspection_Cleaning_Demo doc:
AI_Inspection_Cleaning_Demo\docs\ui\HC-Window-CustomTitleBar-Guide.md
Bonus knowledge:
- The code-behind partial class must change its base class to
HandyControl.Controls.Window. - The
ContentPresenterhostingNonClientAreaContenthasWindowChrome.IsHitTestVisibleInChrome="True", so do not set a Background on the root container (not evenTransparent) — in background-less empty areas, hit-testing passes through to the chrome caption, preserving the "drag the window" behavior; elements with a background receive events normally. This is exactly how Windows Terminal makes the empty area next to the tabs draggable. - When the window is inactive, HC drops the
NonClientAreaContentopacity to 0.8 (a template trigger) — this is normal behavior.
Pitfall 2: TemplateBinding does not work on the root element of a ControlTemplate
While writing a slimmed-down template for hc:TabControl, using hc:TabPanel directly as the template root made every TemplateBinding (TabItemWidth, TabItemHeight, IsTabFillEnabled) fail silently — the panel never received the width/height settings, and tabs measured to 0 width and disappeared.
<!-- ❌ TabPanel as template root: every TemplateBinding fails -->
<ControlTemplate TargetType="hc:TabControl">
<hc:TabPanel x:Name="PART_HeaderPanel"
TabItemWidth="{TemplateBinding TabItemWidth}" .../>
</ControlTemplate>
<!-- ✅ just wrap it in a Border -->
<ControlTemplate TargetType="hc:TabControl">
<Border>
<hc:TabPanel x:Name="PART_HeaderPanel"
MinHeight="{TemplateBinding TabItemHeight}"
TabItemHeight="{TemplateBinding TabItemHeight}"
TabItemWidth="{TemplateBinding TabItemWidth}"
IsTabFillEnabled="{TemplateBinding IsTabFillEnabled}"
HorizontalAlignment="Left" IsItemsHost="True"/>
</Border>
</ControlTemplate>
This is an existing WPF limitation (not an HC bug); HC's own TabControlPlusTemplate also wraps the TabPanel in a Border. When writing a custom template, keeping the PART_HeaderPanel name is enough to keep HC's drag-to-reorder and FluidMove animation working (TabControl.OnApplyTemplate null-checks all the other PARTs, so they can be omitted).
Pitfall 3: TabPanel's measure cache gets poisoned by the first tab added after the window is loaded
Symptom: the tab containers are generated correctly, the styles are correct, and TabPanel's internal _oldSize computes the correct 190×36 — but TabPanel.DesiredSize stays stuck at 0,36 → the whole tab strip has 0 width and is invisible. Forcing InvalidateMeasure(), or even setting ForceUpdate = true via reflection, cannot recover it.
Root cause (HC TabPanel.cs):
public TabPanel()
{
Loaded += (s, e) =>
{
if (_isLoaded) return;
ForceUpdate = true;
Measure(new Size(DesiredSize.Width, ActualHeight)); // ← the culprit
ForceUpdate = false;
...
};
}
TabPanel.Loaded re-measures itself using its own current DesiredSize as the measure constraint. Typical HC usage (tabs exist in XAML or before Loaded) is fine; but our first tab is created in Window.Loaded, and WPF broadcasts Loaded parent-first — by the time TabPanel.Loaded fires, the panel is still empty (DesiredSize.Width == 0), so it measures itself with a width constraint of 0:
- WPF's
MeasureCoreclampsDesiredSizeto the measure constraint → DesiredSize gets clamped to 0. - An element's measure constraint is cached; subsequent re-measures triggered by
InvalidateMeasure()still reuse the previous constraint (0). - The parents (Border / TabControl) have valid measures of their own and will never re-measure this panel with a proper constraint (∞) again → permanently stuck at 0 width.
Fix: after creating a tab, invalidate the panel together with its entire ancestor chain up to the TabControl, forcing the parents to re-measure with a clean constraint:
/// <summary>Call after creating a tab; repairs HC TabPanel's poisoned measure cache.</summary>
private void RefreshTabStrip() {
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => {
DependencyObject d = FindVisualChild<HandyControl.Controls.TabPanel>(tabControl);
while (d is UIElement el) {
el.InvalidateMeasure();
if (ReferenceEquals(d, tabControl)) break;
d = VisualTreeHelper.GetParent(d);
}
}));
}
Debugging takeaway: looking only at ActualWidth/ActualHeight is misleading (arranged children look normal); the key indicator is the mismatch between DesiredSize and the internal _oldSize — _oldSize=190,36 but DesiredSize=0,36 means MeasureOverride's return value was clamped away by MeasureCore's constraint. The problem lies in "who measured the panel with what constraint", not in the panel computing the wrong size itself.
Side notes (not pitfalls, but useful)
- The
hc:namespace already coversHandyControl.Interactivity—hc:ControlCommands.Close,hc:Interaction.Behaviors, andhc:FluidMoveBehaviorare all usable with thehc:prefix directly; no extraclr-namespacedeclaration needed. - Close button in a custom TabItem template: attaching
Command="hc:ControlCommands.Close"reuses HC's close pipeline (fires theClosing/Closedrouted events, automatically removes from ItemsSource) — no custom click handler needed. hc:TabPaneltab-width mechanism: withIsTabFillEnabled="False", tabs use a fixed width (TabItemWidth, default 200); withTrue, the TabControl's full width is divided evenly. Windows-Terminal style uses fixed width +HorizontalAlignment="Left".- Middle-click to close a tab: set
CanBeClosedByMiddleButton="True"onhc:TabControl. - HC TabItem's drag-to-reorder lives in the mouse-event handlers in
TabItem.cs(at the container level), independent of the template — completely rewriting the ControlTemplate does not break dragging.