Skip to main content

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\HandyControl Result: ClassicTerminal/MainWindow.xaml (commit c8dcb8e, 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>
  1. You must write Style="{StaticResource WindowWin10}" explicitly — HC's automatic style application in its static constructor (StyleProperty.OverrideMetadata + the ResourceHelper static cache) can fail in a real project.
  2. 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 inside WindowWin10Template, 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 ContentPresenter hosting NonClientAreaContent has WindowChrome.IsHitTestVisibleInChrome="True", so do not set a Background on the root container (not even Transparent) — 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 NonClientAreaContent opacity 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:

  1. WPF's MeasureCore clamps DesiredSize to the measure constraint → DesiredSize gets clamped to 0.
  2. An element's measure constraint is cached; subsequent re-measures triggered by InvalidateMeasure() still reuse the previous constraint (0).
  3. 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 covers HandyControl.Interactivityhc:ControlCommands.Close, hc:Interaction.Behaviors, and hc:FluidMoveBehavior are all usable with the hc: prefix directly; no extra clr-namespace declaration needed.
  • Close button in a custom TabItem template: attaching Command="hc:ControlCommands.Close" reuses HC's close pipeline (fires the Closing/Closed routed events, automatically removes from ItemsSource) — no custom click handler needed.
  • hc:TabPanel tab-width mechanism: with IsTabFillEnabled="False", tabs use a fixed width (TabItemWidth, default 200); with True, 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" on hc: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.