From fceb3982fc1aaecf2efd0631359400d423853d9d Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:50:27 +0300 Subject: [PATCH 01/16] add-taskbar-blob-shape-mod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Taskbar Blob Shape mod which replaces the rounded-rectangle indicator behind Windows 11 taskbar buttons with a parametric "blob" — a tab-like shape whose top edge is flat and wide, with concave flares at the top corners so each button reads as a tab merging into the desktop above it. --- mods/taskbar-blob-shape.wh.cpp | 990 +++++++++++++++++++++++++++++++++ 1 file changed, 990 insertions(+) create mode 100644 mods/taskbar-blob-shape.wh.cpp diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp new file mode 100644 index 0000000000..bc5cc57441 --- /dev/null +++ b/mods/taskbar-blob-shape.wh.cpp @@ -0,0 +1,990 @@ +// ==WindhawkMod== +// @id taskbar-blob-shape +// @name Taskbar Blob Shape +// @description Injects a customizable blob shape behind active taskbar items. +// @version 0.2.0 +// @author Deen-0x +// @github https://github.com/Deen-0x +// @include explorer.exe +// @architecture x86-64 +// @compilerOptions -lole32 -loleaut32 -lruntimeobject +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# Taskbar Blob Shape + +Adds a blob shape behind active taskbar buttons. + +Every taskbar button gets its own blob shape, shown or hidden as the +button's running indicator state changes. The shapes are hosted in the +taskbar's RootGrid (above the task list's clipping region, so the flares +render fully) and each one is glued to its button with a composition +expression, so it follows the button through reordering, reflow, and +animations on the render thread. + +The blob shape is a rounded rectangle whose top extends upward and flares +outward with concave (outside) corner radii, ending in a flat top edge: + +- **Width / Height**: the main area of the blob shape ('auto' matches the + button's background element). The blob shape is anchored so this area is + centered on it. +- **Top corner radius**: the radius of the concave top flares. The flares + are circular quarter arcs, so this also sets how far the blob shape + extends upward and outward. Total size is + (Width + 2*TopRadius) x (Height + TopRadius). +- **Bottom corner radius**: the convex bottom corners of the blob shape. + +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- BlobShape: + - Dimensions: 'auto, auto' + $name: Custom blob shape dimensions (Width, Height) + $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). + - Margins: '0, 0, 0, 0' + $name: Custom blob shape margin (Left, Top, Right, Bottom) + $description: Offset the blob shape (e.g. '0, 4, 0, 0' pushes it down 4px). Leave empty to disable. + - BottomRadius: '4.0' + $name: Bottom corner radius + $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). + - TopRadius: '6.0' + $name: Top corner radius + $description: The radius of the concave top flare corners. The blob shape extends upward and sideways by this amount. Set to 0 to disable the flare. + $name: Blob Shape Settings +- Colors: + - BgOpacity: '1.0, 1.0' + $name: Background opacity (Light, Dark) + $description: Multiplier for the background fill opacity (e.g. 0.8, 0.5). Set to 1.0 to keep original alpha. + - CustomColor: "" + $name: Custom blob shape color + $description: Hex color code. Supports multi-color gradients (e.g. '#FF0000, #00FF00') and light|dark separation (e.g. 'light1, light2 | dark1, dark2'). Leave empty to use the system accent color. + $name: Color Settings +*/ +// ==/WindhawkModSettings== + +#include +#include +#undef GetCurrentTime +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct Settings { + double BottomRadius = 4.0; + double TopRadius = 6.0; + + double CustomWidth = -1.0; + double CustomHeight = -1.0; + winrt::Windows::UI::Xaml::Thickness CustomMargin = {0,0,0,0}; + bool HasCustomMargin = false; + + double BgOpacityLight = 1.0; + double BgOpacityDark = 1.0; + + std::vector ParsedLightColor; + std::vector ParsedDarkColor; +} g_settings; + +std::mutex g_settingsMutex; + +// One entry per taskbar button that received a blob shape. The blob lives +// inside the button's IconPanel, so the XAML tree keeps it positioned; the +// entry only caches lookups and last-applied parameters. +struct BlobEntry { + winrt::weak_ref button; + winrt::weak_ref blobShape; + winrt::weak_ref grid; + winrt::weak_ref anchor; + winrt::event_token sizeToken{}; + + // Whether the blob's Translation is glued to its button's offset chain, + // and the adjustment constant it was bound with. The blob stays hidden + // until the binding succeeds. + bool bound = false; + float boundAdjX = -1e9f, boundAdjY = -1e9f; + + // Last applied geometry, so state changes (hover, press) don't rebuild. + double geoW = -1.0, geoH = -1.0, geoRt = -1.0, geoRb = -1.0; +}; +std::mutex g_blobEntriesMutex; +std::vector>* g_blobEntries = new std::vector>(); +std::atomic g_unloading{false}; + +std::atomic g_taskbarViewDllLoaded{false}; +HMODULE g_taskbarViewModule = nullptr; + +std::optional ParseHexColor(std::wstring_view hexView) { + if (hexView.empty()) return std::nullopt; + std::wstring hex(hexView); + hex.erase(0, hex.find_first_not_of(L" \t\r\n")); + if (hex.empty()) return std::nullopt; + hex.erase(hex.find_last_not_of(L" \t\r\n") + 1); + if (hex[0] == L'#') hex.erase(0, 1); + if (hex.length() == 6) hex = L"FF" + hex; + if (hex.length() != 8) return std::nullopt; + try { + uint32_t val = std::stoul(hex, nullptr, 16); + return winrt::Windows::UI::Color{ + (uint8_t)((val >> 24) & 0xFF), + (uint8_t)((val >> 16) & 0xFF), + (uint8_t)((val >> 8) & 0xFF), + (uint8_t)(val & 0xFF) + }; + } catch (...) { + return std::nullopt; + } +} + +void ParseDoublePair(PCWSTR str, double& outLight, double& outDark, double defaultVal = 1.0) { + outLight = defaultVal; outDark = defaultVal; + if (!str || !str[0]) return; + std::wstring ws(str); + size_t comma = ws.find(L','); + if (comma != std::wstring::npos) { + try { outLight = std::stod(ws.substr(0, comma)); } catch (...) {} + try { outDark = std::stod(ws.substr(comma + 1)); } catch (...) {} + } else { + try { + outLight = std::stod(ws); + outDark = outLight; + } catch (...) {} + } +} + +double ParseDouble(PCWSTR str, double defaultVal, double minVal = 0.0) { + if (!str || !str[0]) return defaultVal; + try { + double val = std::stod(str); + if (std::isnan(val) || val < minVal) return defaultVal; + return val; + } catch (...) { + return defaultVal; + } +} + +void ParseThickness(PCWSTR str, winrt::Windows::UI::Xaml::Thickness& outThickness) { + double outL = 0.0, outT = 0.0, outR = 0.0, outB = 0.0; + outThickness = winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(0, 0, 0, 0); + if (!str) return; + std::wstring ws(str); + if (ws.empty()) return; + + std::vector vals; + size_t pos = 0; + while (pos < ws.length()) { + while (pos < ws.length() && (ws[pos] == L' ' || ws[pos] == L',')) pos++; + if (pos >= ws.length()) break; + size_t nextComma = ws.find(L',', pos); + if (nextComma == std::wstring::npos) nextComma = ws.length(); + try { + double val = std::stod(ws.substr(pos, nextComma - pos)); + if (std::isnan(val)) val = 0.0; + vals.push_back(val); + } catch (...) {} + pos = nextComma + 1; + } + + if (vals.size() == 1) { + outL = outT = outR = outB = vals[0]; + } else if (vals.size() == 2) { + outT = outB = vals[0]; + outL = outR = vals[1]; + } else if (vals.size() >= 4) { + outL = vals[0]; outT = vals[1]; outR = vals[2]; outB = vals[3]; + } else if (vals.size() > 0) { + outL = outT = outR = outB = vals[0]; + } + + outThickness = winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(outL, outT, outR, outB); +} + +void ParseGradientColorPair(PCWSTR str, std::vector& light, std::vector& dark) { + light.clear(); dark.clear(); + if (!str || !str[0]) return; + std::wstring ws(str); + + size_t pipe = ws.find(L'|'); + std::wstring lightStr = (pipe != std::wstring::npos) ? ws.substr(0, pipe) : ws; + std::wstring darkStr = (pipe != std::wstring::npos) ? ws.substr(pipe + 1) : ws; + + auto parseColors = [](std::wstring s, std::vector& outList) { + size_t pos = 0; + while (pos < s.length()) { + size_t next = s.find(L',', pos); + std::wstring part = (next == std::wstring::npos) ? s.substr(pos) : s.substr(pos, next - pos); + size_t start = part.find_first_not_of(L" \t\r\n"); + if (start != std::wstring::npos) part.erase(0, start); + size_t end = part.find_last_not_of(L" \t\r\n"); + if (end != std::wstring::npos) part.erase(end + 1); + if (!part.empty()) { + auto c = ParseHexColor(part); + if (c.has_value()) outList.push_back(c.value()); + } + if (next == std::wstring::npos) break; + pos = next + 1; + } + }; + + parseColors(lightStr, light); + parseColors(darkStr, dark); +} + +void LoadSettings() { + std::lock_guard settingsLock(g_settingsMutex); + + WindhawkUtils::StringSetting dimStr(Wh_GetStringSetting(L"BlobShape.Dimensions")); + g_settings.CustomWidth = -1.0; + g_settings.CustomHeight = -1.0; + if (dimStr.get()[0]) { + std::wstring ws(dimStr.get()); + size_t comma = ws.find(L','); + auto parseDim = [](std::wstring s) -> double { + size_t start = s.find_first_not_of(L" \t\r\n"); + if (start != std::wstring::npos) s.erase(0, start); + size_t end = s.find_last_not_of(L" \t\r\n"); + if (end != std::wstring::npos) s.erase(end + 1); + if (s == L"auto" || s.empty()) return -1.0; + try { return std::stod(s); } catch (...) { return -1.0; } + }; + if (comma != std::wstring::npos) { + g_settings.CustomWidth = parseDim(ws.substr(0, comma)); + g_settings.CustomHeight = parseDim(ws.substr(comma + 1)); + } else { + g_settings.CustomWidth = parseDim(ws); + g_settings.CustomHeight = g_settings.CustomWidth; + } + } + + WindhawkUtils::StringSetting marginStr(Wh_GetStringSetting(L"BlobShape.Margins")); + g_settings.HasCustomMargin = false; + if (marginStr.get()[0]) { + ParseThickness(marginStr.get(), g_settings.CustomMargin); + std::wstring ms(marginStr.get()); + size_t start = ms.find_first_not_of(L" \t\r\n"); + if (start != std::wstring::npos) g_settings.HasCustomMargin = true; + } + + WindhawkUtils::StringSetting radiusStr(Wh_GetStringSetting(L"BlobShape.BottomRadius")); + g_settings.BottomRadius = ParseDouble(radiusStr.get(), 4.0); + + WindhawkUtils::StringSetting topRadiusStr(Wh_GetStringSetting(L"BlobShape.TopRadius")); + g_settings.TopRadius = ParseDouble(topRadiusStr.get(), 6.0); + + WindhawkUtils::StringSetting customCStr(Wh_GetStringSetting(L"Colors.CustomColor")); + ParseGradientColorPair(customCStr.get(), g_settings.ParsedLightColor, g_settings.ParsedDarkColor); + + WindhawkUtils::StringSetting bgOpStr(Wh_GetStringSetting(L"Colors.BgOpacity")); + ParseDoublePair(bgOpStr.get(), g_settings.BgOpacityLight, g_settings.BgOpacityDark, 1.0); +} + +inline winrt::Windows::UI::Color ApplyOpacity(winrt::Windows::UI::Color c, double opacity) { + if (opacity < 0.0) opacity = 0.0; + if (opacity > 1.0) opacity = 1.0; + c.A = static_cast(c.A * opacity); + return c; +} + +std::vector GetBlobShapeColors(const Settings& localSettings) { + bool isLight = (winrt::Windows::UI::Xaml::Application::Current().RequestedTheme() == winrt::Windows::UI::Xaml::ApplicationTheme::Light); + double opacity = isLight ? localSettings.BgOpacityLight : localSettings.BgOpacityDark; + + auto c = isLight ? localSettings.ParsedLightColor : localSettings.ParsedDarkColor; + if (!c.empty()) { + for (auto& col : c) col = ApplyOpacity(col, opacity); + return c; + } + + // No custom color configured: fall back to the system accent color. + auto res = winrt::Windows::UI::Xaml::Application::Current().Resources(); + auto resName = isLight ? L"SystemAccentColorDark1" : L"SystemAccentColorLight2"; + if (res.HasKey(winrt::box_value(resName))) { + return {ApplyOpacity(winrt::unbox_value(res.Lookup(winrt::box_value(resName))), opacity)}; + } + return {ApplyOpacity({255, 0, 120, 212}, opacity)}; +} + +winrt::Windows::UI::Xaml::Media::Brush CreateBrush(const std::vector& colors) { + if (colors.empty()) return nullptr; + if (colors.size() == 1) { + return winrt::Windows::UI::Xaml::Media::SolidColorBrush(colors[0]); + } + + // Multi-color values render as a horizontal gradient. + winrt::Windows::UI::Xaml::Media::LinearGradientBrush brush; + brush.StartPoint({0.0, 0.5}); + brush.EndPoint({1.0, 0.5}); + + auto stops = brush.GradientStops(); + for (size_t i = 0; i < colors.size(); i++) { + winrt::Windows::UI::Xaml::Media::GradientStop stop; + stop.Color(colors[i]); + stop.Offset(colors.size() > 1 ? static_cast(i) / (colors.size() - 1) : 0.0); + stops.Append(stop); + } + return brush; +} + +// Builds the blob shape: +// +// ___________________________________ +// \ / <- concave flares (Rt x Rt, circular) +// | | +// | main area | <- W x H +// \_______________________________/ <- convex bottom corners (Rb) +// +// Coordinate space: (0,0) is the top-left flare tip. The extension height +// equals the top corner radius, so the total size is +// (W + 2*Rt) x (H + Rt). +winrt::Windows::UI::Xaml::Media::PathGeometry BuildBlobShapeGeometry(double W, double H, double Rt, double Rb) { + using winrt::Windows::UI::Xaml::Media::PathFigure; + using winrt::Windows::UI::Xaml::Media::PathGeometry; + using winrt::Windows::UI::Xaml::Media::LineSegment; + using winrt::Windows::UI::Xaml::Media::ArcSegment; + using winrt::Windows::UI::Xaml::Media::SweepDirection; + + if (W < 1.0) W = 1.0; + if (H < 1.0) H = 1.0; + if (Rt < 0.0) Rt = 0.0; + Rb = std::clamp(Rb, 0.0, std::min(W / 2.0, H)); + + double Wt = W + 2.0 * Rt; // total width including the flare tips + double Ht = H + Rt; // total height including the extension + + PathFigure fig; + fig.StartPoint({0.0f, 0.0f}); + fig.IsClosed(true); + fig.IsFilled(true); + auto segs = fig.Segments(); + + auto addLine = [&](double x, double y) { + LineSegment s; + s.Point({(float)x, (float)y}); + segs.Append(s); + }; + auto addArc = [&](double x, double y, double rx, double ry, bool clockwise) { + if (rx <= 0.0 || ry <= 0.0) { addLine(x, y); return; } + ArcSegment s; + s.Point({(float)x, (float)y}); + s.Size({(float)rx, (float)ry}); + s.SweepDirection(clockwise ? SweepDirection::Clockwise : SweepDirection::Counterclockwise); + s.IsLargeArc(false); + segs.Append(s); + }; + + addLine(Wt, 0.0); // top edge, left tip -> right tip + addArc(Wt - Rt, Rt, Rt, Rt, false); // top-right concave flare + addLine(Wt - Rt, Ht - Rb); // right side down + addArc(Wt - Rt - Rb, Ht, Rb, Rb, true); // bottom-right convex corner + addLine(Rt + Rb, Ht); // bottom edge + addArc(Rt, Ht - Rb, Rb, Rb, true); // bottom-left convex corner + addLine(Rt, Rt); // left side up + addArc(0.0, 0.0, Rt, Rt, false); // top-left concave flare, back to start + + PathGeometry geo; + geo.Figures().Append(fig); + return geo; +} + +using namespace winrt::Windows::UI::Xaml; +using namespace winrt::Windows::UI::Xaml::Controls; +using namespace winrt::Windows::UI::Xaml::Media; +using namespace winrt::Windows::UI::Xaml::Hosting; +using namespace winrt::Windows::UI::Composition; + +bool IsSafeComPointer(void* p) { + if (!p) return false; + MEMORY_BASIC_INFORMATION mbi; + if (!VirtualQuery(p, &mbi, sizeof(mbi))) return false; + if (mbi.State != MEM_COMMIT || (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false; + + void* vtable = *(void**)p; + if (!vtable) return false; + if (!VirtualQuery(vtable, &mbi, sizeof(mbi))) return false; + if (mbi.State != MEM_COMMIT || (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false; + + void* qi = *(void**)vtable; + if (!qi) return false; + if (!VirtualQuery(qi, &mbi, sizeof(mbi))) return false; + if (mbi.State != MEM_COMMIT || !(mbi.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY))) return false; + + return true; +} + +volatile thread_local bool g_inSafeComCall = false; +volatile thread_local bool g_safeComCrashed = false; +thread_local CONTEXT g_safeComContext; + +LONG CALLBACK SafeComCallVEH(PEXCEPTION_POINTERS ExceptionInfo) { + if (g_inSafeComCall && ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) { + g_inSafeComCall = false; + g_safeComCrashed = true; + RtlRestoreContext(&g_safeComContext, nullptr); + } + return EXCEPTION_CONTINUE_SEARCH; +} + +bool SafeProbeHelper(void* pPtr, const GUID& iid, void** finalOutPtr) { + ::IUnknown* pUnk = (::IUnknown*)pPtr; + PVOID veh = AddVectoredExceptionHandler(1, SafeComCallVEH); + if (!veh) return false; + + bool success = false; + g_safeComCrashed = false; + + RtlCaptureContext(&g_safeComContext); + + if (g_safeComCrashed) { + RemoveVectoredExceptionHandler(veh); + return false; + } + + g_inSafeComCall = true; + void* tempPtr = nullptr; + if (SUCCEEDED(pUnk->QueryInterface(iid, &tempPtr)) && tempPtr) { + // Test if the returned pointer is actually a valid COM object + // by calling AddRef and Release under VEH protection. + ((::IUnknown*)tempPtr)->AddRef(); + ((::IUnknown*)tempPtr)->Release(); + + *finalOutPtr = tempPtr; + success = true; + } + g_inSafeComCall = false; + + RemoveVectoredExceptionHandler(veh); + return success; +} + +bool ProbeForFrameworkElement(void* pThis, int offset, winrt::Windows::UI::Xaml::FrameworkElement& outElem) { + void* pPtr = (void**)pThis + offset; + if (!IsSafeComPointer(pPtr)) return false; + + void* outPtr = nullptr; + if (SafeProbeHelper(pPtr, winrt::guid_of(), &outPtr)) { + winrt::copy_from_abi(outElem, outPtr); + ((::IUnknown*)outPtr)->Release(); + return true; + } + return false; +} + +FrameworkElement GetFrameworkElementFromNative(void* pThis) { + if (!pThis) return nullptr; + winrt::Windows::UI::Xaml::FrameworkElement result{nullptr}; + for (int i = 1; i <= 6; i++) { + if (ProbeForFrameworkElement(pThis, i, result)) return result; + } + return nullptr; +} + +FrameworkElement FindChildByName(FrameworkElement const& parent, std::wstring_view name, int depth = 0) { + if (!parent || depth > 5) return nullptr; + int count = VisualTreeHelper::GetChildrenCount(parent); + for (int i = 0; i < count; i++) { + auto child = VisualTreeHelper::GetChild(parent, i).try_as(); + if (child) { + if (child.Name() == name) return child; + auto result = FindChildByName(child, name, depth + 1); + if (result) return result; + } + } + return nullptr; +} + +VisualStateGroup GetVisualStateGroup(FrameworkElement const& root, std::wstring_view groupName) { + auto groups = VisualStateManager::GetVisualStateGroups(root); + for (auto const& group : groups) { + if (group.Name() == groupName) return group; + } + return nullptr; +} + +// Reads a button's current running indicator state. +bool IsButtonActive(winrt::Windows::UI::Xaml::FrameworkElement const& btn) { + if (!btn) return false; + try { + auto iconPanel = FindChildByName(btn, L"IconPanel"); + auto grp = iconPanel ? GetVisualStateGroup(iconPanel, L"RunningIndicatorStates") : nullptr; + auto st = grp ? grp.CurrentState() : nullptr; + return st && st.Name() == L"ActiveRunningIndicator"; + } catch (...) { + return false; + } +} + +using TaskListButton_UpdateVisualStates_t = void(WINAPI*)(void*); +TaskListButton_UpdateVisualStates_t TaskListButton_UpdateVisualStates_Original; + +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, bool isActive, const Settings& localSettings); + +std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::FrameworkElement const& button) { + std::vector orphans; + std::shared_ptr result; + { + std::lock_guard lock(g_blobEntriesMutex); + + // Prune entries whose button died. Their blob elements live in the + // RootGrid, so they must be removed explicitly. + if (g_blobEntries->size() > 100) { + for (auto it = g_blobEntries->begin(); it != g_blobEntries->end(); ) { + if ((*it)->button.get() == nullptr) { + if (auto blob = (*it)->blobShape.get()) orphans.push_back(blob); + it = g_blobEntries->erase(it); + } else { + ++it; + } + } + } + + for (auto& e : *g_blobEntries) { + if (e->button.get() == button) { result = e; break; } + } + if (!result) { + result = std::make_shared(); + result->button = winrt::make_weak(button); + g_blobEntries->push_back(result); + } + } + + for (auto& blob : orphans) { + if (auto dispatcher = blob.Dispatcher()) { + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [blob]() { + try { + if (auto parent = VisualTreeHelper::GetParent(blob)) { + if (auto panel = parent.try_as()) { + uint32_t index; + if (panel.Children().IndexOf(blob, index)) { + panel.Children().RemoveAt(index); + } + } + } + } catch (...) {} + }); + } + } + return result; +} + +// Locates the RootGrid of the taskbar hosting this button by walking up to +// Taskbar.TaskbarFrame. Returns nullptr while the button isn't rooted yet — +// the next state change or SizeChanged retries. +Grid GetTaskbarRootGrid(winrt::Windows::UI::Xaml::FrameworkElement const& button) { + FrameworkElement current = button; + int depth = 0; + while (current && depth < 20) { + if (winrt::get_class_name(current) == L"Taskbar.TaskbarFrame") { + auto rootGrid = FindChildByName(current, L"RootGrid"); + return rootGrid ? rootGrid.try_as() : nullptr; + } + auto parent = VisualTreeHelper::GetParent(current); + current = parent ? parent.try_as() : nullptr; + depth++; + } + return nullptr; +} + +// Glues the blob's Translation to its button's visual offset chain with a +// composition ExpressionAnimation: +// Translation = sum(chain offsets up to RootGrid) + adj - self.Offset +// Bound once per blob and never retargeted; the render thread then moves the +// blob with the button through every taskbar animation. Returns false while +// the chain can't be resolved (button not fully in the tree yet). +bool BindBlobExpression( + winrt::Windows::UI::Xaml::Shapes::Path const& blobShape, + Grid const& grid, + winrt::Windows::UI::Xaml::FrameworkElement const& button, + float adjX, float adjY) +{ + try { + FrameworkElement gridElem = grid; + std::vector chain; + FrameworkElement e = button; + int depth = 0; + while (e && e != gridElem && depth < 15) { + chain.push_back(ElementCompositionPreview::GetElementVisual(e)); + auto parent = VisualTreeHelper::GetParent(e); + e = parent ? parent.try_as() : nullptr; + depth++; + } + if (!e || e != gridElem || chain.empty()) return false; + + auto vis = ElementCompositionPreview::GetElementVisual(blobShape); + + std::wstring expr; + for (size_t i = 0; i < chain.size(); i++) { + expr += L"p" + std::to_wstring(i) + L".Offset + "; + } + expr += L"adj - self.Offset"; + + auto exp = vis.Compositor().CreateExpressionAnimation(winrt::hstring(expr)); + for (size_t i = 0; i < chain.size(); i++) { + exp.SetReferenceParameter(winrt::hstring(L"p" + std::to_wstring(i)), chain[i]); + } + exp.SetVector3Parameter(L"adj", winrt::Windows::Foundation::Numerics::float3(adjX, adjY, 0.0f)); + exp.SetReferenceParameter(L"self", vis); + vis.Properties().StartAnimation(L"Translation", exp); + return true; + } catch (...) { + return false; + } +} + +// Creates (or updates) the blob shape for a single button. The shape is +// hosted in the taskbar's RootGrid — above the task list's clipping region, +// so the flare tips render fully — and glued to its button with a one-time +// composition expression. Activation is purely an opacity toggle on the +// button's own blob. +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, bool isActive, const Settings& localSettings) { + auto iconPanel = FindChildByName(button, L"IconPanel"); + if (!iconPanel) return; + + auto bg = FindChildByName(iconPanel, L"BackgroundElement"); + FrameworkElement anchor = bg ? bg : iconPanel; + + auto entry = FindOrCreateEntry(button); + + auto grid = entry->grid.get(); + if (!grid) { + grid = GetTaskbarRootGrid(button); + if (!grid) return; // not rooted yet; retried on the next event + entry->grid = winrt::make_weak(grid); + } + + auto blobShape = entry->blobShape.get(); + if (!blobShape) { + blobShape = winrt::Windows::UI::Xaml::Shapes::Path(); + blobShape.Name(L"BlobShape"); + blobShape.IsHitTestVisible(false); + blobShape.Stretch(winrt::Windows::UI::Xaml::Media::Stretch::None); + blobShape.HorizontalAlignment(HorizontalAlignment::Left); + blobShape.VerticalAlignment(VerticalAlignment::Top); + blobShape.Margin(winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(0, 0, -1000, -1000)); + blobShape.Opacity(0.0); + + // Below the task list in z-order so the shapes render behind the + // buttons, like the native indicator. + auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); + uint32_t index = 0; + if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { + grid.Children().InsertAt(index, blobShape); + } else { + grid.Children().Append(blobShape); + } + ElementCompositionPreview::SetIsTranslationEnabled(blobShape, true); + + entry->blobShape = winrt::make_weak(blobShape); + entry->bound = false; + entry->boundAdjX = -1e9f; + entry->boundAdjY = -1e9f; + entry->geoW = -1.0; + } + + // Track the anchor's size so late layout (buttons created before their + // first measure) and size changes re-apply geometry and binding. This is + // a plain XAML event — no timers. + if (entry->anchor.get() != anchor) { + if (auto oldAnchor = entry->anchor.get()) { + try { oldAnchor.SizeChanged(entry->sizeToken); } catch (...) {} + } + entry->anchor = winrt::make_weak(anchor); + std::weak_ptr weakEntry = entry; + entry->sizeToken = anchor.SizeChanged([weakEntry](auto const&, auto const&) { + if (g_unloading) return; + auto e = weakEntry.lock(); + if (!e) return; + auto btn = e->button.get(); + if (!btn) return; + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + try { + EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + } catch (...) {} + }); + } + + // Fill color (cheap no-op when unchanged). + std::vector newColors = GetBlobShapeColors(localSettings); + auto existingSolidBrush = blobShape.Fill().try_as(); + bool sameColor = false; + if (existingSolidBrush && newColors.size() == 1) { + auto c = existingSolidBrush.Color(); + auto n = newColors[0]; + sameColor = (c.A == n.A && c.R == n.R && c.G == n.G && c.B == n.B); + } + if (!sameColor) blobShape.Fill(CreateBrush(newColors)); + + // Geometry and expression binding, once the anchor has a real size. + double bW = anchor.ActualWidth(); + double bH = anchor.ActualHeight(); + if (bW > 0.001) { + double W = localSettings.CustomWidth >= 0.0 ? localSettings.CustomWidth : bW; + double H = localSettings.CustomHeight >= 0.0 ? localSettings.CustomHeight : bH; + double W2 = std::max(1.0, W); + double H2 = std::max(1.0, H); + double Rt = std::max(0.0, localSettings.TopRadius); + double Rb = localSettings.BottomRadius; + + if (std::abs(entry->geoW - W2) > 0.01 || std::abs(entry->geoH - H2) > 0.01 || + std::abs(entry->geoRt - Rt) > 0.01 || std::abs(entry->geoRb - Rb) > 0.01 || + !blobShape.Data()) { + blobShape.Data(BuildBlobShapeGeometry(W2, H2, Rt, Rb)); + blobShape.Width(W2 + 2.0 * Rt); + blobShape.Height(H2 + Rt); + entry->geoW = W2; entry->geoH = H2; + entry->geoRt = Rt; entry->geoRb = Rb; + } + + // Adjustment relative to the button's top-left corner: the anchor's + // offset within the button (rounded, so intra-button render + // transforms can't jitter it), centering for custom dimensions, + // custom margins, and the flare tip offset. + float adjX = (float)(-Rt); + float adjY = (float)(-Rt); + if (localSettings.CustomWidth >= 0.0) adjX += (float)((bW - W) / 2.0); + if (localSettings.CustomHeight >= 0.0) adjY += (float)((bH - H) / 2.0); + if (localSettings.HasCustomMargin) { + adjX += (float)localSettings.CustomMargin.Left; + adjY += (float)localSettings.CustomMargin.Top; + } + if (anchor != button) { + try { + auto intra = anchor.TransformToVisual(button).TransformPoint({0, 0}); + adjX += std::round(intra.X); + adjY += std::round(intra.Y); + } catch (...) {} + } + + if (!entry->bound || + std::abs(entry->boundAdjX - adjX) > 0.5f || + std::abs(entry->boundAdjY - adjY) > 0.5f) { + if (BindBlobExpression(blobShape, grid, button, adjX, adjY)) { + entry->bound = true; + entry->boundAdjX = adjX; + entry->boundAdjY = adjY; + } + } + } + + // Hidden until the expression is glued, so the shape can never render at + // a stale or unbound position. + blobShape.Opacity((isActive && entry->bound) ? 1.0 : 0.0); +} + +void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { + TaskListButton_UpdateVisualStates_Original(pThis); + if (g_unloading) return; + + auto elem = GetFrameworkElementFromNative(pThis); + if (!elem) return; + + auto dispatcher = elem.Dispatcher(); + auto weakElem = winrt::make_weak(elem); + + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [weakElem]() { + if (g_unloading) return; + try { + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + + auto button = weakElem.get(); + if (!button) return; + + auto iconPanel = FindChildByName(button, L"IconPanel"); + auto group = iconPanel ? GetVisualStateGroup(iconPanel, L"RunningIndicatorStates") : nullptr; + auto currentState = group ? group.CurrentState() : nullptr; + bool isActive = (currentState && currentState.Name() == L"ActiveRunningIndicator"); + + auto bgElement = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; + if (bgElement) { + bgElement.Opacity(isActive ? 0.0 : 1.0); + } + + EnsureBlobOnButton(button, isActive, localSettings); + } catch (...) { + Wh_Log(L"Exception in UpdateVisualStates hook"); + } + }); +} + +HMODULE GetTaskbarViewModuleHandle() { + HMODULE m = GetModuleHandle(L"Taskbar.View.dll"); + return m ? m : GetModuleHandle(L"ExplorerExtensions.dll"); +} + +bool HookTaskbarViewDllSymbols(HMODULE module) { + // Taskbar.View.dll, ExplorerExtensions.dll + WindhawkUtils::SYMBOL_HOOK hooks[] = { + { + {LR"(private: void __cdecl winrt::Taskbar::implementation::TaskListButton::UpdateVisualStates(void))"}, + &TaskListButton_UpdateVisualStates_Original, + TaskListButton_UpdateVisualStates_Hook, + false + } + }; + + if (!WindhawkUtils::HookSymbols(module, hooks, ARRAYSIZE(hooks))) { + Wh_Log(L"Failed to hook Taskbar.View.dll symbols"); + return false; + } + return true; +} + +using LoadLibraryExW_t = decltype(&LoadLibraryExW); +LoadLibraryExW_t LoadLibraryExW_Original; + +HMODULE WINAPI LoadLibraryExW_Hook(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + HMODULE module = LoadLibraryExW_Original(lpLibFileName, hFile, dwFlags); + if (module) { + if (!g_taskbarViewDllLoaded && GetTaskbarViewModuleHandle() == module && !g_taskbarViewDllLoaded.exchange(true)) { + g_taskbarViewModule = module; + Wh_Log(L"Taskbar View DLL loaded: %s", lpLibFileName); + std::thread([]() { + Sleep(2000); + HMODULE safeModule = nullptr; + if (!GetModuleHandleExW(0, L"Taskbar.View.dll", &safeModule)) return; + HANDLE hMutex = CreateMutexW(NULL, FALSE, L"Global\\WindhawkElasticModsHookMutex"); + if (hMutex) WaitForSingleObject(hMutex, INFINITE); + if (HookTaskbarViewDllSymbols(safeModule)) Wh_ApplyHookOperations(); + if (hMutex) { ReleaseMutex(hMutex); CloseHandle(hMutex); } + FreeLibrary(safeModule); + }).detach(); + } + } + return module; +} + +BOOL Wh_ModInit() { + Wh_Log(L"Initializing Taskbar Blob Shape Mod"); + LoadSettings(); + + HMODULE m = GetTaskbarViewModuleHandle(); + if (m) { + g_taskbarViewDllLoaded = true; + g_taskbarViewModule = m; + if (!HookTaskbarViewDllSymbols(m)) return FALSE; + } else { + HMODULE kb = GetModuleHandle(L"kernelbase.dll"); + auto pLoadLibraryExW = (decltype(&LoadLibraryExW))GetProcAddress(kb, "LoadLibraryExW"); + if (!WindhawkUtils::SetFunctionHook(pLoadLibraryExW, LoadLibraryExW_Hook, &LoadLibraryExW_Original)) { + Wh_Log(L"Failed to hook LoadLibraryExW"); + return FALSE; + } + } + + return TRUE; +} + +void Wh_ModBeforeUninit() { + Wh_Log(L"Uninitializing Taskbar Blob Shape Mod (Before)"); + g_unloading = true; + + std::vector> localEntries; + { + std::lock_guard lock(g_blobEntriesMutex); + localEntries = *g_blobEntries; + g_blobEntries->clear(); + } + if (localEntries.empty()) return; + + std::shared_ptr eventLifetime(CreateEvent(nullptr, TRUE, FALSE, nullptr), [](HANDLE h) { if(h) CloseHandle(h); }); + auto pending = std::make_shared>((int)localEntries.size()); + + for (auto& entry : localEntries) { + auto blobShape = entry->blobShape.get(); + + auto cleanup = [entry, blobShape]() { + try { + if (auto anchor = entry->anchor.get()) { + try { anchor.SizeChanged(entry->sizeToken); } catch (...) {} + } + if (blobShape) { + auto vis = ElementCompositionPreview::GetElementVisual(blobShape); + vis.Properties().StopAnimation(L"Translation"); + if (auto parent = VisualTreeHelper::GetParent(blobShape)) { + if (auto panel = parent.try_as()) { + uint32_t index; + if (panel.Children().IndexOf(blobShape, index)) { + panel.Children().RemoveAt(index); + } + } + } + } + // Restore the native background indicator on this button. + if (auto btn = entry->button.get()) { + auto iconPanel = FindChildByName(btn, L"IconPanel"); + auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; + if (bg) bg.Opacity(1.0); + } + } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } + }; + + auto dispatcher = blobShape ? blobShape.Dispatcher() : nullptr; + if (dispatcher) { + if (dispatcher.HasThreadAccess()) { + cleanup(); + if (pending->fetch_sub(1) == 1 && eventLifetime.get()) SetEvent(eventLifetime.get()); + } else { + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [cleanup, pending, eventLifetime]() { + cleanup(); + if (pending->fetch_sub(1) == 1 && eventLifetime.get()) SetEvent(eventLifetime.get()); + }); + } + } else { + if (pending->fetch_sub(1) == 1 && eventLifetime.get()) SetEvent(eventLifetime.get()); + } + } + + if (pending->load() > 0 && eventLifetime.get()) { + WaitForSingleObject(eventLifetime.get(), 2000); + } + + Sleep(50); // Let layout settle +} + +void Wh_ModUninit() { + Wh_Log(L"Uninitializing Taskbar Blob Shape Mod"); + delete g_blobEntries; +} + +void Wh_ModSettingsChanged() { + LoadSettings(); + std::vector> localEntries; + { + std::lock_guard lock(g_blobEntriesMutex); + localEntries = *g_blobEntries; + } + for (auto& entry : localEntries) { + auto blobShape = entry->blobShape.get(); + auto dispatcher = blobShape ? blobShape.Dispatcher() : nullptr; + if (!dispatcher) continue; + std::weak_ptr weakEntry = entry; + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Low, [weakEntry]() { + if (g_unloading) return; + auto e = weakEntry.lock(); + if (!e) return; + auto btn = e->button.get(); + if (!btn) return; + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + try { + EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + } catch (...) {} + }); + } +} \ No newline at end of file From c6984164058c176ee4bfb15e591dd709f619abee Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:38:38 +0300 Subject: [PATCH 02/16] Suppression moved into EnsureBlobOnButton | The hook callback dropped its own BackgroundElement | Cleanup restores both: IsVisible(true) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression moved into EnsureBlobOnButton and switched to GetElementVisual(bg).IsVisible(!isActive). Since every path — the hook, SizeChanged, settings changes — funnels through this function, the flag is re-asserted consistently no matter which event fired, and no storyboard the visual state manager plays can override it. The hook callback dropped its own BackgroundElement opacity block — one mechanism, one place. Cleanup restores both: IsVisible(true) on the visual, plus Opacity(1.0) as a belt-and-suspenders restore in case a local zero from an earlier build of the mod is still sitting on a live element when you update without restarting explorer. --- mods/taskbar-blob-shape.wh.cpp | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index bc5cc57441..0fef8032f0 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -657,6 +657,21 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto bg = FindChildByName(iconPanel, L"BackgroundElement"); FrameworkElement anchor = bg ? bg : iconPanel; + // Suppress the native background at the composition level. A plain + // Opacity(0) is a LOCAL value, and the RunningIndicatorStates transition + // storyboards hold ANIMATED values on BackgroundElement — which outrank + // local values in XAML's precedence — letting the native pill reappear + // on top of the blob (seen on cross-monitor activations, where the + // existing button transitions with storyboards; freshly created buttons + // apply their initial state without transitions, hence the asymmetry). + // The hand-off visual's IsVisible flag is outside the XAML property + // system, so no storyboard can override it. + if (bg) { + try { + ElementCompositionPreview::GetElementVisual(bg).IsVisible(!isActive); + } catch (...) {} + } + auto entry = FindOrCreateEntry(button); auto grid = entry->grid.get(); @@ -810,11 +825,6 @@ void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { auto currentState = group ? group.CurrentState() : nullptr; bool isActive = (currentState && currentState.Name() == L"ActiveRunningIndicator"); - auto bgElement = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; - if (bgElement) { - bgElement.Opacity(isActive ? 0.0 : 1.0); - } - EnsureBlobOnButton(button, isActive, localSettings); } catch (...) { Wh_Log(L"Exception in UpdateVisualStates hook"); @@ -929,7 +939,10 @@ void Wh_ModBeforeUninit() { if (auto btn = entry->button.get()) { auto iconPanel = FindChildByName(btn, L"IconPanel"); auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; - if (bg) bg.Opacity(1.0); + if (bg) { + try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); } catch (...) {} + bg.Opacity(1.0); + } } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; @@ -987,4 +1000,4 @@ void Wh_ModSettingsChanged() { } catch (...) {} }); } -} \ No newline at end of file +} From 8bf121e1b1853da3a61b870f4f45914cbfc6e29f Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:29:43 +0300 Subject: [PATCH 03/16] Fix multi monitor window drag blob hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unloaded handler per button (attached once, tracked via unloadAttached + token in the entry): when the button leaves the tree, its blob is hidden immediately and the entry's grid cache and expression binding are invalidated. Since the opacity gate is isActive && bound, the blob physically cannot reappear until a fresh, valid binding exists. Re-parent check for the reuse path: if a recycled container resurfaces under a different taskbar's RootGrid (containers hopping monitors), the existing blob is moved from the old grid into the new one — below its repeater, same as at creation — and forced to rebind against the new chain. The parentGrid != grid comparison also covers the degenerate case of a blob that somehow lost its parent entirely. Cleanup detaches the Unloaded subscription alongside the existing SizeChanged teardown. The Unloaded → invalidate → next-event → re-resolve flow reuses machinery that already existed (GetTaskbarRootGrid on a null grid cache, BindBlobExpression on bound == false), so the steady-state hot path didn't change at all. --- mods/taskbar-blob-shape.wh.cpp | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 0fef8032f0..32fe174aa9 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -112,6 +112,8 @@ struct BlobEntry { winrt::weak_ref grid; winrt::weak_ref anchor; winrt::event_token sizeToken{}; + winrt::event_token unloadToken{}; + bool unloadAttached = false; // Whether the blob's Translation is glued to its button's offset chain, // and the adjustment constant it was bound with. The blob stays hidden @@ -674,6 +676,25 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto entry = FindOrCreateEntry(button); + // Button removal (window moved to another monitor, app closed, container + // recycled) is a LIFECYCLE event, not a state change — UpdateVisualStates + // never fires a final "inactive" for it. Without this, the blob stays + // visible, glued to a detached offset chain that no longer updates: + // a frozen ghost. On Unloaded: hide the blob and invalidate the grid + // cache and expression binding so the next use re-resolves everything. + if (!entry->unloadAttached) { + entry->unloadAttached = true; + std::weak_ptr weakEntry = entry; + entry->unloadToken = button.Unloaded([weakEntry](auto const&, auto const&) { + if (g_unloading) return; + auto e = weakEntry.lock(); + if (!e) return; + e->bound = false; + e->grid = nullptr; + if (auto blob = e->blobShape.get()) blob.Opacity(0.0); + }); + } + auto grid = entry->grid.get(); if (!grid) { grid = GetTaskbarRootGrid(button); @@ -708,6 +729,28 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button entry->boundAdjX = -1e9f; entry->boundAdjY = -1e9f; entry->geoW = -1.0; + } else { + // If the button was re-hosted under a different taskbar's grid + // (containers recycled across monitors), move the blob with it and + // force a rebind against the new chain. + auto parent = VisualTreeHelper::GetParent(blobShape); + auto parentGrid = parent ? parent.try_as() : nullptr; + if (parentGrid != grid) { + if (parentGrid) { + uint32_t oldIndex; + if (parentGrid.Children().IndexOf(blobShape, oldIndex)) { + parentGrid.Children().RemoveAt(oldIndex); + } + } + auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); + uint32_t index = 0; + if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { + grid.Children().InsertAt(index, blobShape); + } else { + grid.Children().Append(blobShape); + } + entry->bound = false; + } } // Track the anchor's size so late layout (buttons created before their @@ -937,6 +980,9 @@ void Wh_ModBeforeUninit() { } // Restore the native background indicator on this button. if (auto btn = entry->button.get()) { + if (entry->unloadAttached) { + try { btn.Unloaded(entry->unloadToken); } catch (...) {} + } auto iconPanel = FindChildByName(btn, L"IconPanel"); auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; if (bg) { From 59a2ddde1e5c02385c4cecc62c1a516155490314 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:24:29 +0300 Subject: [PATCH 04/16] Decouple blob's Y-axis animation while preserving X-axis responsiveness --- mods/taskbar-blob-shape.wh.cpp | 52 +++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 32fe174aa9..1e89ff2eb5 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -119,7 +119,7 @@ struct BlobEntry { // and the adjustment constant it was bound with. The blob stays hidden // until the binding succeeds. bool bound = false; - float boundAdjX = -1e9f, boundAdjY = -1e9f; + float boundAdjX = -1e9f, boundYBase = -1e9f; // Last applied geometry, so state changes (hover, press) don't rebuild. double geoW = -1.0, geoH = -1.0, geoRt = -1.0, geoRb = -1.0; @@ -602,16 +602,20 @@ Grid GetTaskbarRootGrid(winrt::Windows::UI::Xaml::FrameworkElement const& button } // Glues the blob's Translation to its button's visual offset chain with a -// composition ExpressionAnimation: -// Translation = sum(chain offsets up to RootGrid) + adj - self.Offset -// Bound once per blob and never retargeted; the render thread then moves the -// blob with the button through every taskbar animation. Returns false while -// the chain can't be resolved (button not fully in the tree yet). +// composition ExpressionAnimation. Only the X axis is dynamic: +// Translation.X = sum(chain Offset.X up to RootGrid) + adjX - self.Offset.X +// Translation.Y = yBase - self.Offset.Y (constant, from the LAYOUT position) +// X tracking keeps the blob glued through reordering and reflow slides, while +// the layout-based Y pins the blob at its final vertical position instantly — +// entrance animations ("Animation Effects") that slide new buttons upward +// animate the visuals' Offset.Y, which this expression deliberately ignores. +// Bound once per blob and never retargeted. Returns false while the chain +// can't be resolved (button not fully in the tree yet). bool BindBlobExpression( winrt::Windows::UI::Xaml::Shapes::Path const& blobShape, Grid const& grid, winrt::Windows::UI::Xaml::FrameworkElement const& button, - float adjX, float adjY) + float adjX, float yBase) { try { FrameworkElement gridElem = grid; @@ -628,17 +632,18 @@ bool BindBlobExpression( auto vis = ElementCompositionPreview::GetElementVisual(blobShape); - std::wstring expr; + std::wstring expr = L"Vector3("; for (size_t i = 0; i < chain.size(); i++) { - expr += L"p" + std::to_wstring(i) + L".Offset + "; + expr += L"p" + std::to_wstring(i) + L".Offset.X + "; } - expr += L"adj - self.Offset"; + expr += L"adjX - self.Offset.X, yBase - self.Offset.Y, 0.0f)"; auto exp = vis.Compositor().CreateExpressionAnimation(winrt::hstring(expr)); for (size_t i = 0; i < chain.size(); i++) { exp.SetReferenceParameter(winrt::hstring(L"p" + std::to_wstring(i)), chain[i]); } - exp.SetVector3Parameter(L"adj", winrt::Windows::Foundation::Numerics::float3(adjX, adjY, 0.0f)); + exp.SetScalarParameter(L"adjX", adjX); + exp.SetScalarParameter(L"yBase", yBase); exp.SetReferenceParameter(L"self", vis); vis.Properties().StartAnimation(L"Translation", exp); return true; @@ -727,7 +732,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button entry->blobShape = winrt::make_weak(blobShape); entry->bound = false; entry->boundAdjX = -1e9f; - entry->boundAdjY = -1e9f; + entry->boundYBase = -1e9f; entry->geoW = -1.0; } else { // If the button was re-hosted under a different taskbar's grid @@ -828,13 +833,26 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button } catch (...) {} } - if (!entry->bound || - std::abs(entry->boundAdjX - adjX) > 0.5f || - std::abs(entry->boundAdjY - adjY) > 0.5f) { - if (BindBlobExpression(blobShape, grid, button, adjX, adjY)) { + // The Y coordinate comes from the LAYOUT position — TransformToVisual + // reflects layout, not composition animations — so entrance slides + // never displace the blob vertically. It only changes with taskbar + // size/DPI changes, which re-run this via SizeChanged and rebind. + bool haveYBase = false; + float yBase = 0.0f; + try { + auto layoutPos = button.TransformToVisual(grid).TransformPoint({0, 0}); + yBase = std::round(layoutPos.Y + adjY); + haveYBase = true; + } catch (...) {} + + if (haveYBase && + (!entry->bound || + std::abs(entry->boundAdjX - adjX) > 0.5f || + std::abs(entry->boundYBase - yBase) > 0.5f)) { + if (BindBlobExpression(blobShape, grid, button, adjX, yBase)) { entry->bound = true; entry->boundAdjX = adjX; - entry->boundAdjY = adjY; + entry->boundYBase = yBase; } } } From ae0d5dc41921d35654e88de95eeb3e3fb53f8020 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:44:00 +0300 Subject: [PATCH 05/16] script improvements 1. VEH/probing removed 2. Loader hook inlined. 3. Suppression driven by the final state. 4. Unloaded is a full teardown. 7. Margins honor all four values. --- mods/taskbar-blob-shape.wh.cpp | 243 ++++++++++++++------------------- 1 file changed, 101 insertions(+), 142 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 1e89ff2eb5..b8f2d9b908 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -2,7 +2,7 @@ // @id taskbar-blob-shape // @name Taskbar Blob Shape // @description Injects a customizable blob shape behind active taskbar items. -// @version 0.2.0 +// @version 1.0.0 // @author Deen-0x // @github https://github.com/Deen-0x // @include explorer.exe @@ -16,6 +16,11 @@ Adds a blob shape behind active taskbar buttons. +![Taskbar Blob Shape](https://i.imgur.com/REPLACE-ME.png) + +Based on **Taskbar Elastic WinUI Pill** and the unreleased **Taskbar Elastic +Border** by **Lockframe**. + Every taskbar button gets its own blob shape, shown or hidden as the button's running indicator state changes. The shapes are hosted in the taskbar's RootGrid (above the task list's clipping region, so the flares @@ -46,7 +51,7 @@ outward with concave (outside) corner radii, ending in a flat top edge: $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). - Margins: '0, 0, 0, 0' $name: Custom blob shape margin (Left, Top, Right, Bottom) - $description: Offset the blob shape (e.g. '0, 4, 0, 0' pushes it down 4px). Leave empty to disable. + $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Leave empty to disable. - BottomRadius: '4.0' $name: Bottom corner radius $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). @@ -65,7 +70,6 @@ outward with concave (outside) corner radii, ending in a flat top edge: */ // ==/WindhawkModSettings== -#include #include #undef GetCurrentTime #include @@ -205,8 +209,9 @@ void ParseThickness(PCWSTR str, winrt::Windows::UI::Xaml::Thickness& outThicknes if (vals.size() == 1) { outL = outT = outR = outB = vals[0]; } else if (vals.size() == 2) { - outT = outB = vals[0]; - outL = outR = vals[1]; + // XAML convention: "horizontal,vertical" + outL = outR = vals[0]; + outT = outB = vals[1]; } else if (vals.size() >= 4) { outL = vals[0]; outT = vals[1]; outR = vals[2]; outB = vals[3]; } else if (vals.size() > 0) { @@ -409,90 +414,20 @@ using namespace winrt::Windows::UI::Xaml::Media; using namespace winrt::Windows::UI::Xaml::Hosting; using namespace winrt::Windows::UI::Composition; -bool IsSafeComPointer(void* p) { - if (!p) return false; - MEMORY_BASIC_INFORMATION mbi; - if (!VirtualQuery(p, &mbi, sizeof(mbi))) return false; - if (mbi.State != MEM_COMMIT || (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false; - - void* vtable = *(void**)p; - if (!vtable) return false; - if (!VirtualQuery(vtable, &mbi, sizeof(mbi))) return false; - if (mbi.State != MEM_COMMIT || (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS))) return false; - - void* qi = *(void**)vtable; - if (!qi) return false; - if (!VirtualQuery(qi, &mbi, sizeof(mbi))) return false; - if (mbi.State != MEM_COMMIT || !(mbi.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY))) return false; - - return true; -} - -volatile thread_local bool g_inSafeComCall = false; -volatile thread_local bool g_safeComCrashed = false; -thread_local CONTEXT g_safeComContext; - -LONG CALLBACK SafeComCallVEH(PEXCEPTION_POINTERS ExceptionInfo) { - if (g_inSafeComCall && ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) { - g_inSafeComCall = false; - g_safeComCrashed = true; - RtlRestoreContext(&g_safeComContext, nullptr); - } - return EXCEPTION_CONTINUE_SEARCH; -} - -bool SafeProbeHelper(void* pPtr, const GUID& iid, void** finalOutPtr) { - ::IUnknown* pUnk = (::IUnknown*)pPtr; - PVOID veh = AddVectoredExceptionHandler(1, SafeComCallVEH); - if (!veh) return false; - - bool success = false; - g_safeComCrashed = false; - - RtlCaptureContext(&g_safeComContext); - - if (g_safeComCrashed) { - RemoveVectoredExceptionHandler(veh); - return false; - } - - g_inSafeComCall = true; - void* tempPtr = nullptr; - if (SUCCEEDED(pUnk->QueryInterface(iid, &tempPtr)) && tempPtr) { - // Test if the returned pointer is actually a valid COM object - // by calling AddRef and Release under VEH protection. - ((::IUnknown*)tempPtr)->AddRef(); - ((::IUnknown*)tempPtr)->Release(); - - *finalOutPtr = tempPtr; - success = true; - } - g_inSafeComCall = false; - - RemoveVectoredExceptionHandler(veh); - return success; -} - -bool ProbeForFrameworkElement(void* pThis, int offset, winrt::Windows::UI::Xaml::FrameworkElement& outElem) { - void* pPtr = (void**)pThis + offset; - if (!IsSafeComPointer(pPtr)) return false; - - void* outPtr = nullptr; - if (SafeProbeHelper(pPtr, winrt::guid_of(), &outPtr)) { - winrt::copy_from_abi(outElem, outPtr); - ((::IUnknown*)outPtr)->Release(); - return true; - } - return false; -} - +// Resolves the XAML element from the native TaskListButton pointer the same +// way the mods this derives from do (taskbar-elastic-pill, taskbar-labels): +// the IUnknown of the WinRT object sits at a fixed offset in the +// implementation type. FrameworkElement GetFrameworkElementFromNative(void* pThis) { if (!pThis) return nullptr; - winrt::Windows::UI::Xaml::FrameworkElement result{nullptr}; - for (int i = 1; i <= 6; i++) { - if (ProbeForFrameworkElement(pThis, i, result)) return result; + try { + void* iUnknownPtr = (void**)pThis + 3; + winrt::Windows::Foundation::IUnknown iUnknown; + winrt::copy_from_abi(iUnknown, iUnknownPtr); + return iUnknown.try_as(); + } catch (...) { + return nullptr; } - return nullptr; } FrameworkElement FindChildByName(FrameworkElement const& parent, std::wstring_view name, int depth = 0) { @@ -541,21 +476,19 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework { std::lock_guard lock(g_blobEntriesMutex); - // Prune entries whose button died. Their blob elements live in the - // RootGrid, so they must be removed explicitly. - if (g_blobEntries->size() > 100) { - for (auto it = g_blobEntries->begin(); it != g_blobEntries->end(); ) { - if ((*it)->button.get() == nullptr) { - if (auto blob = (*it)->blobShape.get()) orphans.push_back(blob); - it = g_blobEntries->erase(it); - } else { - ++it; - } + // Prune entries whose button died without an Unloaded (rare). Their + // blob elements live in the RootGrid, so they must be removed + // explicitly. This runs on every lookup — the list is small since + // Unloaded drops entries eagerly. + for (auto it = g_blobEntries->begin(); it != g_blobEntries->end(); ) { + auto btn = (*it)->button.get(); + if (!btn) { + if (auto blob = (*it)->blobShape.get()) orphans.push_back(blob); + it = g_blobEntries->erase(it); + continue; } - } - - for (auto& e : *g_blobEntries) { - if (e->button.get() == button) { result = e; break; } + if (btn == button) result = *it; + ++it; } if (!result) { result = std::make_shared(); @@ -664,29 +597,28 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto bg = FindChildByName(iconPanel, L"BackgroundElement"); FrameworkElement anchor = bg ? bg : iconPanel; - // Suppress the native background at the composition level. A plain - // Opacity(0) is a LOCAL value, and the RunningIndicatorStates transition - // storyboards hold ANIMATED values on BackgroundElement — which outrank - // local values in XAML's precedence — letting the native pill reappear - // on top of the blob (seen on cross-monitor activations, where the - // existing button transitions with storyboards; freshly created buttons - // apply their initial state without transitions, hence the asymmetry). - // The hand-off visual's IsVisible flag is outside the XAML property - // system, so no storyboard can override it. - if (bg) { - try { - ElementCompositionPreview::GetElementVisual(bg).IsVisible(!isActive); - } catch (...) {} - } + // Suppression of the native background is decided at the END, from the + // same condition that shows the blob, so no failure path can leave an + // active button with neither indicator. The hand-off visual's IsVisible + // flag is used instead of a local Opacity because the + // RunningIndicatorStates transition storyboards hold ANIMATED values on + // BackgroundElement, which outrank local values in XAML's precedence. + auto setNativeHidden = [&](bool hidden) { + if (bg) { + try { + ElementCompositionPreview::GetElementVisual(bg).IsVisible(!hidden); + } catch (...) {} + } + }; auto entry = FindOrCreateEntry(button); // Button removal (window moved to another monitor, app closed, container // recycled) is a LIFECYCLE event, not a state change — UpdateVisualStates - // never fires a final "inactive" for it. Without this, the blob stays - // visible, glued to a detached offset chain that no longer updates: - // a frozen ghost. On Unloaded: hide the blob and invalidate the grid - // cache and expression binding so the next use re-resolves everything. + // never fires a final "inactive" for it. On Unloaded, tear the blob down + // completely: stop its expression (which holds references to the whole + // visual chain), unparent it, restore the native indicator, and drop the + // entry. The create path rebuilds everything if the container is reused. if (!entry->unloadAttached) { entry->unloadAttached = true; std::weak_ptr weakEntry = entry; @@ -694,16 +626,50 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button if (g_unloading) return; auto e = weakEntry.lock(); if (!e) return; - e->bound = false; - e->grid = nullptr; - if (auto blob = e->blobShape.get()) blob.Opacity(0.0); + if (auto blob = e->blobShape.get()) { + try { + ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); + } catch (...) {} + try { + if (auto parent = VisualTreeHelper::GetParent(blob)) { + if (auto panel = parent.try_as()) { + uint32_t index; + if (panel.Children().IndexOf(blob, index)) { + panel.Children().RemoveAt(index); + } + } + } + } catch (...) {} + } + if (auto btn = e->button.get()) { + try { + auto iconPanel = FindChildByName(btn, L"IconPanel"); + auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; + if (bg) ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); + } catch (...) {} + if (auto anchor = e->anchor.get()) { + try { anchor.SizeChanged(e->sizeToken); } catch (...) {} + } + try { btn.Unloaded(e->unloadToken); } catch (...) {} + } + std::lock_guard lock(g_blobEntriesMutex); + g_blobEntries->erase( + std::remove_if(g_blobEntries->begin(), g_blobEntries->end(), + [&](auto& x) { return x == e; }), + g_blobEntries->end()); }); } auto grid = entry->grid.get(); if (!grid) { grid = GetTaskbarRootGrid(button); - if (!grid) return; // not rooted yet; retried on the next event + if (!grid) { + // Not rooted under a TaskbarFrame — e.g. buttons in the overflow + // flyout live in a separate XAML island. Leave the native + // indicator fully in charge there. + setNativeHidden(false); + return; + } entry->grid = winrt::make_weak(grid); } @@ -822,8 +788,8 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button if (localSettings.CustomWidth >= 0.0) adjX += (float)((bW - W) / 2.0); if (localSettings.CustomHeight >= 0.0) adjY += (float)((bH - H) / 2.0); if (localSettings.HasCustomMargin) { - adjX += (float)localSettings.CustomMargin.Left; - adjY += (float)localSettings.CustomMargin.Top; + adjX += (float)(localSettings.CustomMargin.Left - localSettings.CustomMargin.Right); + adjY += (float)(localSettings.CustomMargin.Top - localSettings.CustomMargin.Bottom); } if (anchor != button) { try { @@ -857,9 +823,12 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button } } - // Hidden until the expression is glued, so the shape can never render at - // a stale or unbound position. - blobShape.Opacity((isActive && entry->bound) ? 1.0 : 0.0); + // One condition drives both the blob and the native indicator: the + // native background only disappears when the blob is actually shown in + // its place, and the blob never renders at a stale or unbound position. + bool show = isActive && entry->bound; + blobShape.Opacity(show ? 1.0 : 0.0); + setNativeHidden(show); } void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { @@ -921,21 +890,11 @@ LoadLibraryExW_t LoadLibraryExW_Original; HMODULE WINAPI LoadLibraryExW_Hook(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { HMODULE module = LoadLibraryExW_Original(lpLibFileName, hFile, dwFlags); - if (module) { - if (!g_taskbarViewDllLoaded && GetTaskbarViewModuleHandle() == module && !g_taskbarViewDllLoaded.exchange(true)) { - g_taskbarViewModule = module; - Wh_Log(L"Taskbar View DLL loaded: %s", lpLibFileName); - std::thread([]() { - Sleep(2000); - HMODULE safeModule = nullptr; - if (!GetModuleHandleExW(0, L"Taskbar.View.dll", &safeModule)) return; - HANDLE hMutex = CreateMutexW(NULL, FALSE, L"Global\\WindhawkElasticModsHookMutex"); - if (hMutex) WaitForSingleObject(hMutex, INFINITE); - if (HookTaskbarViewDllSymbols(safeModule)) Wh_ApplyHookOperations(); - if (hMutex) { ReleaseMutex(hMutex); CloseHandle(hMutex); } - FreeLibrary(safeModule); - }).detach(); - } + if (module && !g_taskbarViewDllLoaded && + GetTaskbarViewModuleHandle() == module && !g_taskbarViewDllLoaded.exchange(true)) { + g_taskbarViewModule = module; + Wh_Log(L"Taskbar View DLL loaded: %s", lpLibFileName); + if (HookTaskbarViewDllSymbols(module)) Wh_ApplyHookOperations(); } return module; } From ba04094bd9b2c852686c46e46fe479b244d49671 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:58:04 +0300 Subject: [PATCH 06/16] add a screenshot and the attribution. --- mods/taskbar-blob-shape.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index b8f2d9b908..bbea52d315 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -16,7 +16,7 @@ Adds a blob shape behind active taskbar buttons. -![Taskbar Blob Shape](https://i.imgur.com/REPLACE-ME.png) +![Taskbar Blob Shape](https://raw.githubusercontent.com/Deen-0x/windhawk-assets/refs/heads/main/taskbar-blob-shape/demo.gif) Based on **Taskbar Elastic WinUI Pill** and the unreleased **Taskbar Elastic Border** by **Lockframe**. From 0e93a0c41f4c499a515479e5e133c30141ef6549 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:11:46 +0300 Subject: [PATCH 07/16] Optional improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Global container — plain std::vector> g_blobEntries;, all -> sites converted, delete gone; Wh_ModUninit is now just the log line. The reviewer's reasoning holds: nothing in the entries has cross-process or destructor-order hazards. 2. Gradient fast path — BlobEntry gained lastColors; the fill comparison is now an element-wise ARGB compare against the last applied list (guarded by blobShape.Fill() for the recreated-blob case), so gradient configs no longer rebuild a LinearGradientBrush plus stops on every hover and press. 3. Dead bg.Opacity(1.0) removed from cleanup — correct call; it was a leftover belt-and-suspenders from the local-opacity era. 4. BlobEntry comment now says RootGrid hosting + expression glue. That stale line was a fossil from the IconPanel iteration. 5. Includes — , , and (the latter for item 9's iswxdigit) added explicitly. 6. Sleep(50) dropped; the event wait above it is the actual synchronization. 7. Hook deduplicated — the inline state lookup became EnsureBlobOnButton(button, IsButtonActive(button), localSettings). GetVisualStateGroup keeps its one remaining caller inside IsButtonActive. 8. Dispatcher null-check added in the hook before RunAsync, matching the other call sites. 9. ParseHexColor validates every character with iswxdigit before std::stoul, so #FFxyz123 now falls through to the accent fallback instead of silently parsing as 0xFF. --- mods/taskbar-blob-shape.wh.cpp | 76 +++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index bbea52d315..478903b09b 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -16,7 +16,7 @@ Adds a blob shape behind active taskbar buttons. -![Taskbar Blob Shape](https://raw.githubusercontent.com/Deen-0x/windhawk-assets/refs/heads/main/taskbar-blob-shape/demo.gif) +![Taskbar Blob Shape](https://raw.githubusercontent.com/Deen-0x/windhawk-assets/main/taskbar-blob-shape/demo.gif) Based on **Taskbar Elastic WinUI Pill** and the unreleased **Taskbar Elastic Border** by **Lockframe**. @@ -83,6 +83,9 @@ outward with concave (outside) corner radii, ending in a flat top edge: #include #include #include +#include +#include +#include #include #include #include @@ -107,9 +110,10 @@ struct Settings { std::mutex g_settingsMutex; -// One entry per taskbar button that received a blob shape. The blob lives -// inside the button's IconPanel, so the XAML tree keeps it positioned; the -// entry only caches lookups and last-applied parameters. +// One entry per taskbar button that received a blob shape. The blob is +// hosted in the taskbar's RootGrid and glued to its button with a +// composition expression; the entry caches lookups, event tokens, and +// last-applied parameters. struct BlobEntry { winrt::weak_ref button; winrt::weak_ref blobShape; @@ -125,11 +129,13 @@ struct BlobEntry { bool bound = false; float boundAdjX = -1e9f, boundYBase = -1e9f; - // Last applied geometry, so state changes (hover, press) don't rebuild. + // Last applied geometry and fill colors, so state changes (hover, + // press) don't rebuild anything. double geoW = -1.0, geoH = -1.0, geoRt = -1.0, geoRb = -1.0; + std::vector lastColors; }; std::mutex g_blobEntriesMutex; -std::vector>* g_blobEntries = new std::vector>(); +std::vector> g_blobEntries; std::atomic g_unloading{false}; std::atomic g_taskbarViewDllLoaded{false}; @@ -144,6 +150,9 @@ std::optional ParseHexColor(std::wstring_view hexView if (hex[0] == L'#') hex.erase(0, 1); if (hex.length() == 6) hex = L"FF" + hex; if (hex.length() != 8) return std::nullopt; + for (wchar_t ch : hex) { + if (!iswxdigit(ch)) return std::nullopt; + } try { uint32_t val = std::stoul(hex, nullptr, 16); return winrt::Windows::UI::Color{ @@ -480,11 +489,11 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework // blob elements live in the RootGrid, so they must be removed // explicitly. This runs on every lookup — the list is small since // Unloaded drops entries eagerly. - for (auto it = g_blobEntries->begin(); it != g_blobEntries->end(); ) { + for (auto it = g_blobEntries.begin(); it != g_blobEntries.end(); ) { auto btn = (*it)->button.get(); if (!btn) { if (auto blob = (*it)->blobShape.get()) orphans.push_back(blob); - it = g_blobEntries->erase(it); + it = g_blobEntries.erase(it); continue; } if (btn == button) result = *it; @@ -493,7 +502,7 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework if (!result) { result = std::make_shared(); result->button = winrt::make_weak(button); - g_blobEntries->push_back(result); + g_blobEntries.push_back(result); } } @@ -653,10 +662,10 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button try { btn.Unloaded(e->unloadToken); } catch (...) {} } std::lock_guard lock(g_blobEntriesMutex); - g_blobEntries->erase( - std::remove_if(g_blobEntries->begin(), g_blobEntries->end(), + g_blobEntries.erase( + std::remove_if(g_blobEntries.begin(), g_blobEntries.end(), [&](auto& x) { return x == e; }), - g_blobEntries->end()); + g_blobEntries.end()); }); } @@ -747,16 +756,25 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button }); } - // Fill color (cheap no-op when unchanged). + // Fill color — compared against the last applied color list so gradient + // configurations don't rebuild a brush (and its stops) on every + // hover/press state change. std::vector newColors = GetBlobShapeColors(localSettings); - auto existingSolidBrush = blobShape.Fill().try_as(); - bool sameColor = false; - if (existingSolidBrush && newColors.size() == 1) { - auto c = existingSolidBrush.Color(); - auto n = newColors[0]; - sameColor = (c.A == n.A && c.R == n.R && c.G == n.G && c.B == n.B); + bool sameColor = blobShape.Fill() && newColors.size() == entry->lastColors.size(); + if (sameColor) { + for (size_t i = 0; i < newColors.size(); i++) { + auto& a = newColors[i]; + auto& b = entry->lastColors[i]; + if (a.A != b.A || a.R != b.R || a.G != b.G || a.B != b.B) { + sameColor = false; + break; + } + } + } + if (!sameColor) { + blobShape.Fill(CreateBrush(newColors)); + entry->lastColors = std::move(newColors); } - if (!sameColor) blobShape.Fill(CreateBrush(newColors)); // Geometry and expression binding, once the anchor has a real size. double bW = anchor.ActualWidth(); @@ -839,6 +857,7 @@ void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { if (!elem) return; auto dispatcher = elem.Dispatcher(); + if (!dispatcher) return; auto weakElem = winrt::make_weak(elem); dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [weakElem]() { @@ -850,12 +869,7 @@ void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { auto button = weakElem.get(); if (!button) return; - auto iconPanel = FindChildByName(button, L"IconPanel"); - auto group = iconPanel ? GetVisualStateGroup(iconPanel, L"RunningIndicatorStates") : nullptr; - auto currentState = group ? group.CurrentState() : nullptr; - bool isActive = (currentState && currentState.Name() == L"ActiveRunningIndicator"); - - EnsureBlobOnButton(button, isActive, localSettings); + EnsureBlobOnButton(button, IsButtonActive(button), localSettings); } catch (...) { Wh_Log(L"Exception in UpdateVisualStates hook"); } @@ -927,8 +941,8 @@ void Wh_ModBeforeUninit() { std::vector> localEntries; { std::lock_guard lock(g_blobEntriesMutex); - localEntries = *g_blobEntries; - g_blobEntries->clear(); + localEntries = g_blobEntries; + g_blobEntries.clear(); } if (localEntries.empty()) return; @@ -964,7 +978,6 @@ void Wh_ModBeforeUninit() { auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; if (bg) { try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); } catch (...) {} - bg.Opacity(1.0); } } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } @@ -989,13 +1002,10 @@ void Wh_ModBeforeUninit() { if (pending->load() > 0 && eventLifetime.get()) { WaitForSingleObject(eventLifetime.get(), 2000); } - - Sleep(50); // Let layout settle } void Wh_ModUninit() { Wh_Log(L"Uninitializing Taskbar Blob Shape Mod"); - delete g_blobEntries; } void Wh_ModSettingsChanged() { @@ -1003,7 +1013,7 @@ void Wh_ModSettingsChanged() { std::vector> localEntries; { std::lock_guard lock(g_blobEntriesMutex); - localEntries = *g_blobEntries; + localEntries = g_blobEntries; } for (auto& entry : localEntries) { auto blobShape = entry->blobShape.get(); From bc92b260862d2f04b778c166042d6607c05be99a Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:29:02 +0300 Subject: [PATCH 08/16] address functionality notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunningIndicator suppression. EnsureBlobOnButton now also resolves RunningIndicator in the IconPanel, and setNativeHidden toggles both elements' hand-off IsVisible from the single show condition. So: blob shown → background and indicator line hidden; blob not shown (inactive, unbound, overflow flyout) → both fully native. Restoration was added everywhere the background is restored — the Unloaded teardown and the uninstall cleanup — so disable/uninstall brings the line back on every button. Theme handling. Two changes, as flagged plus a deeper issue: IsElementLightMode is back (element ActualTheme first, RequestedTheme only for the Default fallback), and GetBlobShapeColors now takes isLight from the caller, resolved from the button. This fixes the correctness problem — RequestedTheme is frozen at process start, so after a live theme switch it wasn't just stale, it was wrong for as long as the session lasted. Each blob subscribes to ActualThemeChanged at creation (token in the entry, detached in both teardown paths). A theme switch propagates through the tree, every blob's handler fires on its own UI thread, re-runs the ensure, and the lastColors comparison from the previous round turns that into exactly one brush swap per blob — including inactive/hidden ones, so nothing shows the old theme's fill when it next activates. --- mods/taskbar-blob-shape.wh.cpp | 54 ++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 478903b09b..e2f874e9be 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -121,6 +121,7 @@ struct BlobEntry { winrt::weak_ref anchor; winrt::event_token sizeToken{}; winrt::event_token unloadToken{}; + winrt::event_token themeToken{}; bool unloadAttached = false; // Whether the blob's Translation is glued to its button's offset chain, @@ -316,8 +317,22 @@ inline winrt::Windows::UI::Color ApplyOpacity(winrt::Windows::UI::Color c, doubl return c; } -std::vector GetBlobShapeColors(const Settings& localSettings) { - bool isLight = (winrt::Windows::UI::Xaml::Application::Current().RequestedTheme() == winrt::Windows::UI::Xaml::ApplicationTheme::Light); +// The taskbar switches themes at runtime via the element tree's ActualTheme; +// Application::Current().RequestedTheme() is fixed at startup, so it only +// serves as the fallback when the element reports Default. +bool IsElementLightMode(winrt::Windows::UI::Xaml::FrameworkElement const& element) { + if (!element) return false; + try { + auto theme = element.ActualTheme(); + if (theme == winrt::Windows::UI::Xaml::ElementTheme::Light) return true; + if (theme == winrt::Windows::UI::Xaml::ElementTheme::Dark) return false; + return winrt::Windows::UI::Xaml::Application::Current().RequestedTheme() == winrt::Windows::UI::Xaml::ApplicationTheme::Light; + } catch (...) { + return false; + } +} + +std::vector GetBlobShapeColors(const Settings& localSettings, bool isLight) { double opacity = isLight ? localSettings.BgOpacityLight : localSettings.BgOpacityDark; auto c = isLight ? localSettings.ParsedLightColor : localSettings.ParsedDarkColor; @@ -612,12 +627,21 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // flag is used instead of a local Opacity because the // RunningIndicatorStates transition storyboards hold ANIMATED values on // BackgroundElement, which outrank local values in XAML's precedence. + // The RunningIndicator line is part of the button and renders above the + // blob (which sits below the repeater in z-order), so it is suppressed + // together with the background while the blob is shown. + auto runningIndicator = FindChildByName(iconPanel, L"RunningIndicator"); auto setNativeHidden = [&](bool hidden) { if (bg) { try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(!hidden); } catch (...) {} } + if (runningIndicator) { + try { + ElementCompositionPreview::GetElementVisual(runningIndicator).IsVisible(!hidden); + } catch (...) {} + } }; auto entry = FindOrCreateEntry(button); @@ -636,6 +660,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto e = weakEntry.lock(); if (!e) return; if (auto blob = e->blobShape.get()) { + try { blob.ActualThemeChanged(e->themeToken); } catch (...) {} try { ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); } catch (...) {} @@ -655,6 +680,8 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto iconPanel = FindChildByName(btn, L"IconPanel"); auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; if (bg) ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); + auto indicator = iconPanel ? FindChildByName(iconPanel, L"RunningIndicator") : nullptr; + if (indicator) ElementCompositionPreview::GetElementVisual(indicator).IsVisible(true); } catch (...) {} if (auto anchor = e->anchor.get()) { try { anchor.SizeChanged(e->sizeToken); } catch (...) {} @@ -709,6 +736,22 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button entry->boundAdjX = -1e9f; entry->boundYBase = -1e9f; entry->geoW = -1.0; + + // Repaint on theme switches: without this, untouched buttons would + // keep the previous theme's fill until their next state change. + std::weak_ptr weakThemeEntry = entry; + entry->themeToken = blobShape.ActualThemeChanged([weakThemeEntry](auto const&, auto const&) { + if (g_unloading) return; + auto e = weakThemeEntry.lock(); + if (!e) return; + auto btn = e->button.get(); + if (!btn) return; + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + try { + EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + } catch (...) {} + }); } else { // If the button was re-hosted under a different taskbar's grid // (containers recycled across monitors), move the blob with it and @@ -759,7 +802,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // Fill color — compared against the last applied color list so gradient // configurations don't rebuild a brush (and its stops) on every // hover/press state change. - std::vector newColors = GetBlobShapeColors(localSettings); + std::vector newColors = GetBlobShapeColors(localSettings, IsElementLightMode(button)); bool sameColor = blobShape.Fill() && newColors.size() == entry->lastColors.size(); if (sameColor) { for (size_t i = 0; i < newColors.size(); i++) { @@ -958,6 +1001,7 @@ void Wh_ModBeforeUninit() { try { anchor.SizeChanged(entry->sizeToken); } catch (...) {} } if (blobShape) { + try { blobShape.ActualThemeChanged(entry->themeToken); } catch (...) {} auto vis = ElementCompositionPreview::GetElementVisual(blobShape); vis.Properties().StopAnimation(L"Translation"); if (auto parent = VisualTreeHelper::GetParent(blobShape)) { @@ -979,6 +1023,10 @@ void Wh_ModBeforeUninit() { if (bg) { try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); } catch (...) {} } + auto indicator = iconPanel ? FindChildByName(iconPanel, L"RunningIndicator") : nullptr; + if (indicator) { + try { ElementCompositionPreview::GetElementVisual(indicator).IsVisible(true); } catch (...) {} + } } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; From 496d3b4e28b96220335edbea6eac3d1a28593dbb Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:20:11 +0300 Subject: [PATCH 09/16] Anchor the shape's top edge to the taskbar top; drop Y sampling and verification --- mods/taskbar-blob-shape.wh.cpp | 258 ++++++++++++++++++--------------- 1 file changed, 143 insertions(+), 115 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index e2f874e9be..cda1959f6b 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -32,8 +32,9 @@ The blob shape is a rounded rectangle whose top extends upward and flares outward with concave (outside) corner radii, ending in a flat top edge: - **Width / Height**: the main area of the blob shape ('auto' matches the - button's background element). The blob shape is anchored so this area is - centered on it. + button's background element). Horizontally the shape is centered on the + button; vertically its flat top edge is anchored to the top of the + taskbar, with the body hanging downward. - **Top corner radius**: the radius of the concave top flares. The flares are circular quarter arcs, so this also sets how far the blob shape extends upward and outward. Total size is @@ -48,10 +49,10 @@ outward with concave (outside) corner radii, ending in a flat top edge: - BlobShape: - Dimensions: 'auto, auto' $name: Custom blob shape dimensions (Width, Height) - $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). + $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). The body hangs down from the top of the taskbar. - Margins: '0, 0, 0, 0' $name: Custom blob shape margin (Left, Top, Right, Bottom) - $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Leave empty to disable. + $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Vertical offsets are measured from the top of the taskbar. Leave empty to disable. - BottomRadius: '4.0' $name: Bottom corner radius $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). @@ -73,7 +74,6 @@ outward with concave (outside) corner radii, ending in a flat top edge: #include #undef GetCurrentTime #include -#include #include #include #include @@ -121,12 +121,15 @@ struct BlobEntry { winrt::weak_ref anchor; winrt::event_token sizeToken{}; winrt::event_token unloadToken{}; + winrt::event_token loadedToken{}; winrt::event_token themeToken{}; bool unloadAttached = false; + bool loadedAttached = false; // Whether the blob's Translation is glued to its button's offset chain, - // and the adjustment constant it was bound with. The blob stays hidden - // until the binding succeeds. + // plus the X adjustment and taskbar-top Y anchor it was bound with + // (compared to detect when a settings change requires a rebind). The + // blob stays hidden until the binding succeeds. bool bound = false; float boundAdjX = -1e9f, boundYBase = -1e9f; @@ -140,7 +143,6 @@ std::vector> g_blobEntries; std::atomic g_unloading{false}; std::atomic g_taskbarViewDllLoaded{false}; -HMODULE g_taskbarViewModule = nullptr; std::optional ParseHexColor(std::wstring_view hexView) { if (hexView.empty()) return std::nullopt; @@ -187,8 +189,8 @@ double ParseDouble(PCWSTR str, double defaultVal, double minVal = 0.0) { if (!str || !str[0]) return defaultVal; try { double val = std::stod(str); - if (std::isnan(val) || val < minVal) return defaultVal; - return val; + if (std::isnan(val)) return defaultVal; + return std::max(val, minVal); } catch (...) { return defaultVal; } @@ -365,7 +367,7 @@ winrt::Windows::UI::Xaml::Media::Brush CreateBrush(const std::vector 1 ? static_cast(i) / (colors.size() - 1) : 0.0); + stop.Offset(static_cast(i) / (colors.size() - 1)); stops.Append(stop); } return brush; @@ -436,7 +438,6 @@ using namespace winrt::Windows::UI::Xaml; using namespace winrt::Windows::UI::Xaml::Controls; using namespace winrt::Windows::UI::Xaml::Media; using namespace winrt::Windows::UI::Xaml::Hosting; -using namespace winrt::Windows::UI::Composition; // Resolves the XAML element from the native TaskListButton pointer the same // way the mods this derives from do (taskbar-elastic-pill, taskbar-labels): @@ -489,6 +490,46 @@ bool IsButtonActive(winrt::Windows::UI::Xaml::FrameworkElement const& btn) { } } +// Removes an element from its parent panel, if it has one. +void RemoveFromParentPanel(winrt::Windows::UI::Xaml::UIElement const& element) { + try { + if (auto parent = VisualTreeHelper::GetParent(element)) { + if (auto panel = parent.try_as()) { + uint32_t index; + if (panel.Children().IndexOf(element, index)) { + panel.Children().RemoveAt(index); + } + } + } + } catch (...) {} +} + +// Hands rendering of the native indicator visuals back to XAML. +void RestoreNativeVisuals(winrt::Windows::UI::Xaml::FrameworkElement const& btn) { + try { + auto iconPanel = FindChildByName(btn, L"IconPanel"); + if (!iconPanel) return; + if (auto bg = FindChildByName(iconPanel, L"BackgroundElement")) { + ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); + } + if (auto indicator = FindChildByName(iconPanel, L"RunningIndicator")) { + ElementCompositionPreview::GetElementVisual(indicator).IsVisible(true); + } + } catch (...) {} +} + +// Inserts the blob below the task list in z-order so it renders behind the +// buttons, like the native indicator. +void InsertBlobBelowRepeater(Grid const& grid, winrt::Windows::UI::Xaml::Shapes::Path const& blobShape) { + auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); + uint32_t index = 0; + if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { + grid.Children().InsertAt(index, blobShape); + } else { + grid.Children().Append(blobShape); + } +} + using TaskListButton_UpdateVisualStates_t = void(WINAPI*)(void*); TaskListButton_UpdateVisualStates_t TaskListButton_UpdateVisualStates_Original; @@ -524,16 +565,7 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework for (auto& blob : orphans) { if (auto dispatcher = blob.Dispatcher()) { dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [blob]() { - try { - if (auto parent = VisualTreeHelper::GetParent(blob)) { - if (auto panel = parent.try_as()) { - uint32_t index; - if (panel.Children().IndexOf(blob, index)) { - panel.Children().RemoveAt(index); - } - } - } - } catch (...) {} + RemoveFromParentPanel(blob); }); } } @@ -561,11 +593,11 @@ Grid GetTaskbarRootGrid(winrt::Windows::UI::Xaml::FrameworkElement const& button // Glues the blob's Translation to its button's visual offset chain with a // composition ExpressionAnimation. Only the X axis is dynamic: // Translation.X = sum(chain Offset.X up to RootGrid) + adjX - self.Offset.X -// Translation.Y = yBase - self.Offset.Y (constant, from the LAYOUT position) +// Translation.Y = yBase - self.Offset.Y (constant: taskbar top + margins) // X tracking keeps the blob glued through reordering and reflow slides, while -// the layout-based Y pins the blob at its final vertical position instantly — -// entrance animations ("Animation Effects") that slide new buttons upward -// animate the visuals' Offset.Y, which this expression deliberately ignores. +// the flat top edge stays anchored to the top of the taskbar — Y never +// follows the button, so entrance animations ("Animation Effects") and +// mid-transition layout can't displace or freeze the shape vertically. // Bound once per blob and never retargeted. Returns false while the chain // can't be resolved (button not fully in the tree yet). bool BindBlobExpression( @@ -648,10 +680,11 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // Button removal (window moved to another monitor, app closed, container // recycled) is a LIFECYCLE event, not a state change — UpdateVisualStates - // never fires a final "inactive" for it. On Unloaded, tear the blob down - // completely: stop its expression (which holds references to the whole - // visual chain), unparent it, restore the native indicator, and drop the - // entry. The create path rebuilds everything if the container is reused. + // never fires a final "inactive" for it. On a genuine Unloaded, tear the + // blob down completely: stop its expression (which holds references to + // the whole visual chain), unparent it, restore the native indicator, + // and drop the entry. The create path rebuilds everything if the + // container is used again later. if (!entry->unloadAttached) { entry->unloadAttached = true; std::weak_ptr weakEntry = entry; @@ -659,34 +692,47 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button if (g_unloading) return; auto e = weakEntry.lock(); if (!e) return; + auto btn = e->button.get(); + + // XAML delivers Unloaded asynchronously: a container that was + // detached and immediately re-attached (recycling, cross-monitor + // moves, pinned->running item swaps) receives a STALE Unloaded + // while it is already live again — possibly right after the hook + // built its blob. Tearing down on a stale event destroys a valid + // blob with no event left to rebuild it. If the button is still + // loaded, re-resolve and rebind against the settled tree instead + // of tearing down. + bool stillLoaded = false; + if (btn) { + try { stillLoaded = btn.IsLoaded(); } catch (...) {} + } + if (stillLoaded) { + e->bound = false; + e->grid = nullptr; // may sit under a different taskbar now + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + try { + EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + } catch (...) {} + return; + } + if (auto blob = e->blobShape.get()) { try { blob.ActualThemeChanged(e->themeToken); } catch (...) {} try { ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); } catch (...) {} - try { - if (auto parent = VisualTreeHelper::GetParent(blob)) { - if (auto panel = parent.try_as()) { - uint32_t index; - if (panel.Children().IndexOf(blob, index)) { - panel.Children().RemoveAt(index); - } - } - } - } catch (...) {} + RemoveFromParentPanel(blob); } - if (auto btn = e->button.get()) { - try { - auto iconPanel = FindChildByName(btn, L"IconPanel"); - auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; - if (bg) ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); - auto indicator = iconPanel ? FindChildByName(iconPanel, L"RunningIndicator") : nullptr; - if (indicator) ElementCompositionPreview::GetElementVisual(indicator).IsVisible(true); - } catch (...) {} + if (btn) { + RestoreNativeVisuals(btn); if (auto anchor = e->anchor.get()) { try { anchor.SizeChanged(e->sizeToken); } catch (...) {} } try { btn.Unloaded(e->unloadToken); } catch (...) {} + if (e->loadedAttached) { + try { btn.Loaded(e->loadedToken); } catch (...) {} + } } std::lock_guard lock(g_blobEntriesMutex); g_blobEntries.erase( @@ -696,6 +742,30 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button }); } + // Loaded fires after a (re)attached button has been measured and + // arranged — the moment to drop the grid cache and rebind the X chain: + // after a cross-taskbar reattach the cached weak ref can still resolve + // to the OLD (alive) grid, which would make the chain walk fail + // silently. + if (!entry->loadedAttached) { + entry->loadedAttached = true; + std::weak_ptr weakLoadedEntry = entry; + entry->loadedToken = button.Loaded([weakLoadedEntry](auto const&, auto const&) { + if (g_unloading) return; + auto e = weakLoadedEntry.lock(); + if (!e) return; + auto btn = e->button.get(); + if (!btn) return; + e->bound = false; + e->grid = nullptr; + Settings localSettings; + { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } + try { + EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + } catch (...) {} + }); + } + auto grid = entry->grid.get(); if (!grid) { grid = GetTaskbarRootGrid(button); @@ -720,15 +790,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button blobShape.Margin(winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(0, 0, -1000, -1000)); blobShape.Opacity(0.0); - // Below the task list in z-order so the shapes render behind the - // buttons, like the native indicator. - auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); - uint32_t index = 0; - if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { - grid.Children().InsertAt(index, blobShape); - } else { - grid.Children().Append(blobShape); - } + InsertBlobBelowRepeater(grid, blobShape); ElementCompositionPreview::SetIsTranslationEnabled(blobShape, true); entry->blobShape = winrt::make_weak(blobShape); @@ -759,19 +821,8 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto parent = VisualTreeHelper::GetParent(blobShape); auto parentGrid = parent ? parent.try_as() : nullptr; if (parentGrid != grid) { - if (parentGrid) { - uint32_t oldIndex; - if (parentGrid.Children().IndexOf(blobShape, oldIndex)) { - parentGrid.Children().RemoveAt(oldIndex); - } - } - auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); - uint32_t index = 0; - if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { - grid.Children().InsertAt(index, blobShape); - } else { - grid.Children().Append(blobShape); - } + RemoveFromParentPanel(blobShape); + InsertBlobBelowRepeater(grid, blobShape); entry->bound = false; } } @@ -840,42 +891,34 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button entry->geoRt = Rt; entry->geoRb = Rb; } - // Adjustment relative to the button's top-left corner: the anchor's - // offset within the button (rounded, so intra-button render - // transforms can't jitter it), centering for custom dimensions, - // custom margins, and the flare tip offset. + // X adjustment relative to the button's left edge: the anchor's + // horizontal offset within the button (rounded, so intra-button + // render transforms can't jitter it), centering for a custom width, + // and the flare tip offset. float adjX = (float)(-Rt); - float adjY = (float)(-Rt); - if (localSettings.CustomWidth >= 0.0) adjX += (float)((bW - W) / 2.0); - if (localSettings.CustomHeight >= 0.0) adjY += (float)((bH - H) / 2.0); - if (localSettings.HasCustomMargin) { - adjX += (float)(localSettings.CustomMargin.Left - localSettings.CustomMargin.Right); - adjY += (float)(localSettings.CustomMargin.Top - localSettings.CustomMargin.Bottom); - } + if (localSettings.CustomWidth >= 0.0) adjX += (float)((bW - W) / 2.0); if (anchor != button) { try { auto intra = anchor.TransformToVisual(button).TransformPoint({0, 0}); adjX += std::round(intra.X); - adjY += std::round(intra.Y); } catch (...) {} } - // The Y coordinate comes from the LAYOUT position — TransformToVisual - // reflects layout, not composition animations — so entrance slides - // never displace the blob vertically. It only changes with taskbar - // size/DPI changes, which re-run this via SizeChanged and rebind. - bool haveYBase = false; + // The Y coordinate is a structural constant: the flat top edge is + // anchored to the top of the taskbar (the RootGrid origin), not to + // the button — no element position is ever sampled for Y, so no + // transition can displace or freeze the shape vertically. Margins + // shift it from that anchor; the body hangs downward by Height. float yBase = 0.0f; - try { - auto layoutPos = button.TransformToVisual(grid).TransformPoint({0, 0}); - yBase = std::round(layoutPos.Y + adjY); - haveYBase = true; - } catch (...) {} + if (localSettings.HasCustomMargin) { + adjX += (float)(localSettings.CustomMargin.Left - localSettings.CustomMargin.Right); + yBase += (float)(localSettings.CustomMargin.Top - localSettings.CustomMargin.Bottom); + } + yBase = std::round(yBase); - if (haveYBase && - (!entry->bound || - std::abs(entry->boundAdjX - adjX) > 0.5f || - std::abs(entry->boundYBase - yBase) > 0.5f)) { + if (!entry->bound || + std::abs(entry->boundAdjX - adjX) > 0.5f || + std::abs(entry->boundYBase - yBase) > 0.5f) { if (BindBlobExpression(blobShape, grid, button, adjX, yBase)) { entry->bound = true; entry->boundAdjX = adjX; @@ -949,7 +992,6 @@ HMODULE WINAPI LoadLibraryExW_Hook(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dw HMODULE module = LoadLibraryExW_Original(lpLibFileName, hFile, dwFlags); if (module && !g_taskbarViewDllLoaded && GetTaskbarViewModuleHandle() == module && !g_taskbarViewDllLoaded.exchange(true)) { - g_taskbarViewModule = module; Wh_Log(L"Taskbar View DLL loaded: %s", lpLibFileName); if (HookTaskbarViewDllSymbols(module)) Wh_ApplyHookOperations(); } @@ -963,7 +1005,6 @@ BOOL Wh_ModInit() { HMODULE m = GetTaskbarViewModuleHandle(); if (m) { g_taskbarViewDllLoaded = true; - g_taskbarViewModule = m; if (!HookTaskbarViewDllSymbols(m)) return FALSE; } else { HMODULE kb = GetModuleHandle(L"kernelbase.dll"); @@ -1004,29 +1045,16 @@ void Wh_ModBeforeUninit() { try { blobShape.ActualThemeChanged(entry->themeToken); } catch (...) {} auto vis = ElementCompositionPreview::GetElementVisual(blobShape); vis.Properties().StopAnimation(L"Translation"); - if (auto parent = VisualTreeHelper::GetParent(blobShape)) { - if (auto panel = parent.try_as()) { - uint32_t index; - if (panel.Children().IndexOf(blobShape, index)) { - panel.Children().RemoveAt(index); - } - } - } + RemoveFromParentPanel(blobShape); } - // Restore the native background indicator on this button. if (auto btn = entry->button.get()) { if (entry->unloadAttached) { try { btn.Unloaded(entry->unloadToken); } catch (...) {} } - auto iconPanel = FindChildByName(btn, L"IconPanel"); - auto bg = iconPanel ? FindChildByName(iconPanel, L"BackgroundElement") : nullptr; - if (bg) { - try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(true); } catch (...) {} - } - auto indicator = iconPanel ? FindChildByName(iconPanel, L"RunningIndicator") : nullptr; - if (indicator) { - try { ElementCompositionPreview::GetElementVisual(indicator).IsVisible(true); } catch (...) {} + if (entry->loadedAttached) { + try { btn.Loaded(entry->loadedToken); } catch (...) {} } + RestoreNativeVisuals(btn); } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; From 98d48d151d349c291b8eed722e98c5662c324b51 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:30:14 +0300 Subject: [PATCH 10/16] update default settings to match default taskbar --- mods/taskbar-blob-shape.wh.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index cda1959f6b..e8d5dfcf0c 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -47,16 +47,16 @@ outward with concave (outside) corner radii, ending in a flat top edge: // ==WindhawkModSettings== /* - BlobShape: - - Dimensions: 'auto, auto' + - Dimensions: 'auto, 36' $name: Custom blob shape dimensions (Width, Height) $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). The body hangs down from the top of the taskbar. - Margins: '0, 0, 0, 0' $name: Custom blob shape margin (Left, Top, Right, Bottom) $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Vertical offsets are measured from the top of the taskbar. Leave empty to disable. - - BottomRadius: '4.0' + - BottomRadius: '4' $name: Bottom corner radius $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). - - TopRadius: '6.0' + - TopRadius: '8' $name: Top corner radius $description: The radius of the concave top flare corners. The blob shape extends upward and sideways by this amount. Set to 0 to disable the flare. $name: Blob Shape Settings @@ -94,7 +94,7 @@ outward with concave (outside) corner radii, ending in a flat top edge: struct Settings { double BottomRadius = 4.0; - double TopRadius = 6.0; + double TopRadius = 8.0; double CustomWidth = -1.0; double CustomHeight = -1.0; @@ -303,7 +303,7 @@ void LoadSettings() { g_settings.BottomRadius = ParseDouble(radiusStr.get(), 4.0); WindhawkUtils::StringSetting topRadiusStr(Wh_GetStringSetting(L"BlobShape.TopRadius")); - g_settings.TopRadius = ParseDouble(topRadiusStr.get(), 6.0); + g_settings.TopRadius = ParseDouble(topRadiusStr.get(), 8.0); WindhawkUtils::StringSetting customCStr(Wh_GetStringSetting(L"Colors.CustomColor")); ParseGradientColorPair(customCStr.get(), g_settings.ParsedLightColor, g_settings.ParsedDarkColor); From ab1a2aaf492169ce8ee38b6a2ee5484db7f15ee8 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:01:36 +0300 Subject: [PATCH 11/16] resolve button Loaded/Unloaded handlers attached after the mod unloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wh_ModBeforeUninit — btn is resolved alongside the blob, and the dispatcher falls back to btn.Dispatcher() when the blob is null. The cleanup lambda didn't need touching, exactly as the reviewer said: its if (blobShape) guard and separate button branch (which is where the Unloaded/Loaded token revocations live) already do the right thing when there's no blob — it just never ran for those entries. I added a comment spelling out why blob-less entries exist, so nobody "simplifies" this back into the crash later. Wh_ModSettingsChanged — same fallback. Harmless before, but as the reviewer noted, it upgrades behavior for free: flyout-hosted buttons (and any not-yet-rooted button) now re-attempt on a settings change instead of waiting for their next UpdateVisualStates. --- mods/taskbar-blob-shape.wh.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index e8d5dfcf0c..df57432dc4 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -1035,6 +1035,7 @@ void Wh_ModBeforeUninit() { for (auto& entry : localEntries) { auto blobShape = entry->blobShape.get(); + auto btn = entry->button.get(); auto cleanup = [entry, blobShape]() { try { @@ -1059,7 +1060,14 @@ void Wh_ModBeforeUninit() { } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; - auto dispatcher = blobShape ? blobShape.Dispatcher() : nullptr; + // Entries can exist WITHOUT a blob: overflow flyout buttons have no + // Taskbar.TaskbarFrame ancestor, so the RootGrid lookup fails for + // them permanently — but their Loaded/Unloaded handlers are already + // attached. Resolve the cleanup dispatcher from the button as a + // fallback so those handlers are revoked too; left subscribed, they + // point into this DLL and crash Explorer when they fire after unload. + auto dispatcher = blobShape ? blobShape.Dispatcher() + : (btn ? btn.Dispatcher() : nullptr); if (dispatcher) { if (dispatcher.HasThreadAccess()) { cleanup(); @@ -1093,7 +1101,9 @@ void Wh_ModSettingsChanged() { } for (auto& entry : localEntries) { auto blobShape = entry->blobShape.get(); - auto dispatcher = blobShape ? blobShape.Dispatcher() : nullptr; + auto btn = entry->button.get(); + auto dispatcher = blobShape ? blobShape.Dispatcher() + : (btn ? btn.Dispatcher() : nullptr); if (!dispatcher) continue; std::weak_ptr weakEntry = entry; dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Low, [weakEntry]() { From 1c9a0e3a630bdf80828b921b032227e7b417ca31 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:04:58 +0300 Subject: [PATCH 12/16] Optional improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Priority inversion — Wh_ModSettingsChanged now posts at High like the hook and cleanup, so within each dispatcher everything is FIFO relative to the uninit barrier and nothing can be left queued behind it. I went with the reprioritization rather than elastic-pill's trailing-Low-barrier approach because after this change no mod code posts at Low — the barrier would guard an empty class. If a future change ever posts at Low again, the barrier becomes the required companion; the comment says so. 2. Timeout logging — WAIT_TIMEOUT from the 2 s wait now logs "Timed out waiting for blob shape cleanup". As the reviewer put it, that's exactly the breadcrumb a post-unload crash report needs. 3. Orphan pruning — now StopAnimation(L"Translation") before unparenting, matching the other two teardown paths. All three teardowns are symmetric. 4. Walk deduplication — new RefreshBlob(button, settings) is the single entry point for all six triggers: it resolves IconPanel once, reads the indicator state from it, and passes both into EnsureBlobOnButton (whose signature gained the iconPanel parameter). IsButtonActive is gone, folded in — that halves the recursive walks per hover/press. The Tag-stashing half I deliberately declined: Tag on a TaskListButton is shell-owned property surface, and stomping it risks colliding with whatever the shell (or another mod) stores there; the alternative — a raw-pointer map — reintroduces stale-pointer aliasing when recycled memory gets reused. The weak-ref scan is self-validating and O(number of buttons) with trivial per-element cost, so I kept it and would defend that in the PR reply. 5. Margin trick documented — three-line comment on the (0, 0, -1000, -1000) margin explaining it cancels the Path's contribution to the grid's desired size. 6. kernelbase null check — GetProcAddress is guarded and a null function pointer now fails init through the same logged path. 7. Opacity naming — display name is "Blob opacity (Light, Dark)" with a description that says fill opacity; the BgOpacity key is unchanged so existing users' values survive. --- mods/taskbar-blob-shape.wh.cpp | 73 ++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index df57432dc4..97fa00ee58 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -62,8 +62,8 @@ outward with concave (outside) corner radii, ending in a flat top edge: $name: Blob Shape Settings - Colors: - BgOpacity: '1.0, 1.0' - $name: Background opacity (Light, Dark) - $description: Multiplier for the background fill opacity (e.g. 0.8, 0.5). Set to 1.0 to keep original alpha. + $name: Blob opacity (Light, Dark) + $description: Multiplier for the blob shape's fill opacity (e.g. 0.8, 0.5). Set to 1.0 to keep the color's own alpha. - CustomColor: "" $name: Custom blob shape color $description: Hex color code. Supports multi-color gradients (e.g. '#FF0000, #00FF00') and light|dark separation (e.g. 'light1, light2 | dark1, dark2'). Leave empty to use the system accent color. @@ -477,19 +477,6 @@ VisualStateGroup GetVisualStateGroup(FrameworkElement const& root, std::wstring_ return nullptr; } -// Reads a button's current running indicator state. -bool IsButtonActive(winrt::Windows::UI::Xaml::FrameworkElement const& btn) { - if (!btn) return false; - try { - auto iconPanel = FindChildByName(btn, L"IconPanel"); - auto grp = iconPanel ? GetVisualStateGroup(iconPanel, L"RunningIndicatorStates") : nullptr; - auto st = grp ? grp.CurrentState() : nullptr; - return st && st.Name() == L"ActiveRunningIndicator"; - } catch (...) { - return false; - } -} - // Removes an element from its parent panel, if it has one. void RemoveFromParentPanel(winrt::Windows::UI::Xaml::UIElement const& element) { try { @@ -533,7 +520,23 @@ void InsertBlobBelowRepeater(Grid const& grid, winrt::Windows::UI::Xaml::Shapes: using TaskListButton_UpdateVisualStates_t = void(WINAPI*)(void*); TaskListButton_UpdateVisualStates_t TaskListButton_UpdateVisualStates_Original; -void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, bool isActive, const Settings& localSettings); +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings); + +// The single entry point for every trigger (hook, SizeChanged, Loaded, stale +// Unloaded, theme change, settings change): resolves the button's IconPanel +// once, reads the running indicator state from it, and applies the blob — +// instead of each path doing its own recursive tree walks. +void RefreshBlob(winrt::Windows::UI::Xaml::FrameworkElement const& button, const Settings& localSettings) { + auto iconPanel = FindChildByName(button, L"IconPanel"); + if (!iconPanel) return; + bool isActive = false; + try { + auto grp = GetVisualStateGroup(iconPanel, L"RunningIndicatorStates"); + auto st = grp ? grp.CurrentState() : nullptr; + isActive = st && st.Name() == L"ActiveRunningIndicator"; + } catch (...) {} + EnsureBlobOnButton(button, iconPanel, isActive, localSettings); +} std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::FrameworkElement const& button) { std::vector orphans; @@ -565,6 +568,9 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework for (auto& blob : orphans) { if (auto dispatcher = blob.Dispatcher()) { dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [blob]() { + try { + ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); + } catch (...) {} RemoveFromParentPanel(blob); }); } @@ -646,10 +652,7 @@ bool BindBlobExpression( // so the flare tips render fully — and glued to its button with a one-time // composition expression. Activation is purely an opacity toggle on the // button's own blob. -void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, bool isActive, const Settings& localSettings) { - auto iconPanel = FindChildByName(button, L"IconPanel"); - if (!iconPanel) return; - +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings) { auto bg = FindChildByName(iconPanel, L"BackgroundElement"); FrameworkElement anchor = bg ? bg : iconPanel; @@ -712,7 +715,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button Settings localSettings; { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } try { - EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + RefreshBlob(btn, localSettings); } catch (...) {} return; } @@ -761,7 +764,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button Settings localSettings; { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } try { - EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + RefreshBlob(btn, localSettings); } catch (...) {} }); } @@ -787,6 +790,9 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button blobShape.Stretch(winrt::Windows::UI::Xaml::Media::Stretch::None); blobShape.HorizontalAlignment(HorizontalAlignment::Left); blobShape.VerticalAlignment(VerticalAlignment::Top); + // The negative right/bottom margins cancel the Path's contribution + // to the grid's desired size, so the blob never affects taskbar + // layout; positioning is done entirely via the Translation facade. blobShape.Margin(winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(0, 0, -1000, -1000)); blobShape.Opacity(0.0); @@ -811,7 +817,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button Settings localSettings; { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } try { - EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + RefreshBlob(btn, localSettings); } catch (...) {} }); } else { @@ -845,7 +851,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button Settings localSettings; { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } try { - EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + RefreshBlob(btn, localSettings); } catch (...) {} }); } @@ -955,7 +961,7 @@ void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { auto button = weakElem.get(); if (!button) return; - EnsureBlobOnButton(button, IsButtonActive(button), localSettings); + RefreshBlob(button, localSettings); } catch (...) { Wh_Log(L"Exception in UpdateVisualStates hook"); } @@ -1008,8 +1014,9 @@ BOOL Wh_ModInit() { if (!HookTaskbarViewDllSymbols(m)) return FALSE; } else { HMODULE kb = GetModuleHandle(L"kernelbase.dll"); - auto pLoadLibraryExW = (decltype(&LoadLibraryExW))GetProcAddress(kb, "LoadLibraryExW"); - if (!WindhawkUtils::SetFunctionHook(pLoadLibraryExW, LoadLibraryExW_Hook, &LoadLibraryExW_Original)) { + auto pLoadLibraryExW = kb ? (decltype(&LoadLibraryExW))GetProcAddress(kb, "LoadLibraryExW") : nullptr; + if (!pLoadLibraryExW || + !WindhawkUtils::SetFunctionHook(pLoadLibraryExW, LoadLibraryExW_Hook, &LoadLibraryExW_Original)) { Wh_Log(L"Failed to hook LoadLibraryExW"); return FALSE; } @@ -1084,7 +1091,9 @@ void Wh_ModBeforeUninit() { } if (pending->load() > 0 && eventLifetime.get()) { - WaitForSingleObject(eventLifetime.get(), 2000); + if (WaitForSingleObject(eventLifetime.get(), 2000) == WAIT_TIMEOUT) { + Wh_Log(L"Timed out waiting for blob shape cleanup"); + } } } @@ -1106,7 +1115,11 @@ void Wh_ModSettingsChanged() { : (btn ? btn.Dispatcher() : nullptr); if (!dispatcher) continue; std::weak_ptr weakEntry = entry; - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Low, [weakEntry]() { + // High, matching the hook and the uninit cleanup: CoreDispatcher is + // priority-ordered, so a Low item posted here could still be queued + // when the High-priority uninit barrier completes — and would then + // run in an unloaded DLL. + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [weakEntry]() { if (g_unloading) return; auto e = weakEntry.lock(); if (!e) return; @@ -1115,7 +1128,7 @@ void Wh_ModSettingsChanged() { Settings localSettings; { std::lock_guard lock(g_settingsMutex); localSettings = g_settings; } try { - EnsureBlobOnButton(btn, IsButtonActive(btn), localSettings); + RefreshBlob(btn, localSettings); } catch (...) {} }); } From 448e8c59a07b9dc87d76cec6a8b80fdf51986138 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:47:38 +0300 Subject: [PATCH 13/16] optional Improvements by order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Unload fence — g_unloading is now set inside the g_blobEntriesMutex block in Wh_ModBeforeUninit, and FindOrCreateEntry checks it under the same lock, returning nullptr; EnsureBlobOnButton bails on null. The create-after-snapshot window is closed: any callback that raced past an earlier flag check now hits the locked check before it can create an entry or attach handlers. 2. Cross-thread weak refs — scoped as I flagged: entry->blobShape is the one field written on the UI thread and read on the Windhawk thread, so its write (creation block) and the .get() reads in both Wh_ModBeforeUninit and Wh_ModSettingsChanged now happen under the mutex (button was already creation-time-constant under the lock; anchor/grid never cross threads). Same guarantee the reviewer asked for, without wrapping every UI-thread-only field write. 3. !grid early-out — hides an existing blob before restoring the natives, so that path can no longer show both. 4. Fail-safe insertion — InsertBlobBelowRepeater scans direct children only (matching the assumption the index insert depends on, per the vd-switcher reference) and returns bool. Creation logs and bails; the reparent path bails silently (retried on the next event, no log spam). And one gap I caught in my own patch: both fail returns now call setNativeHidden(false) first — otherwise a failed insert could leave a button with the natives suppressed and no blob, which is exactly the invariant item 3 protects. 5. nativeHidden tracking — per-entry flag; setNativeHidden no-ops when the state matches (so untouched buttons — including flyout ones — are never written at all, a small perf win on top of the correctness), and both teardown paths restore only if nativeHidden is set. The lambda now lives after entry acquisition since it needs the entry. 6. ParseThickness — exactly 1, 2, or 4 values; anything else (including 3, and now also 5+) yields zeros, with the accepted counts documented in the Margins description. --- mods/taskbar-blob-shape.wh.cpp | 102 ++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 97fa00ee58..6e2a3b2473 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -52,7 +52,7 @@ outward with concave (outside) corner radii, ending in a flat top edge: $description: Size of the blob shape's main area. Set to 'auto' to match the button's background element, or specify pixel values (e.g., '32, 32'). The body hangs down from the top of the taskbar. - Margins: '0, 0, 0, 0' $name: Custom blob shape margin (Left, Top, Right, Bottom) - $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Vertical offsets are measured from the top of the taskbar. Leave empty to disable. + $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Vertical offsets are measured from the top of the taskbar. Accepts 1, 2 (horizontal, vertical), or 4 values. Leave empty to disable. - BottomRadius: '4' $name: Bottom corner radius $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). @@ -126,6 +126,11 @@ struct BlobEntry { bool unloadAttached = false; bool loadedAttached = false; + // Whether WE hid the native indicator visuals for this button, so the + // restore paths only touch what the mod actually changed (and never + // override a Visibility the shell might set itself). + bool nativeHidden = false; + // Whether the blob's Translation is glued to its button's offset chain, // plus the X adjustment and taskbar-top Y anchor it was bound with // (compared to detect when a settings change requires a rebind). The @@ -224,11 +229,11 @@ void ParseThickness(PCWSTR str, winrt::Windows::UI::Xaml::Thickness& outThicknes // XAML convention: "horizontal,vertical" outL = outR = vals[0]; outT = outB = vals[1]; - } else if (vals.size() >= 4) { + } else if (vals.size() == 4) { outL = vals[0]; outT = vals[1]; outR = vals[2]; outB = vals[3]; - } else if (vals.size() > 0) { - outL = outT = outR = outB = vals[0]; } + // Any other count (e.g. 3 values) is invalid and yields zeros rather + // than silently discarding part of the input. outThickness = winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(outL, outT, outR, outB); } @@ -506,15 +511,21 @@ void RestoreNativeVisuals(winrt::Windows::UI::Xaml::FrameworkElement const& btn) } // Inserts the blob below the task list in z-order so it renders behind the -// buttons, like the native indicator. -void InsertBlobBelowRepeater(Grid const& grid, winrt::Windows::UI::Xaml::Shapes::Path const& blobShape) { - auto repeater = FindChildByName(grid, L"TaskbarFrameRepeater"); - uint32_t index = 0; - if (repeater && grid.Children().IndexOf(repeater.try_as(), index)) { - grid.Children().InsertAt(index, blobShape); - } else { - grid.Children().Append(blobShape); +// buttons, like the native indicator. The repeater is resolved by scanning +// DIRECT children only — that's the assumption the index insertion depends +// on. Returns false (fail safe) rather than appending: an appended blob +// would render on top of the buttons, covering the icons. +bool InsertBlobBelowRepeater(Grid const& grid, winrt::Windows::UI::Xaml::Shapes::Path const& blobShape) { + auto children = grid.Children(); + uint32_t count = children.Size(); + for (uint32_t i = 0; i < count; i++) { + auto child = children.GetAt(i).try_as(); + if (child && child.Name() == L"TaskbarFrameRepeater") { + children.InsertAt(i, blobShape); + return true; + } } + return false; } using TaskListButton_UpdateVisualStates_t = void(WINAPI*)(void*); @@ -543,6 +554,7 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework std::shared_ptr result; { std::lock_guard lock(g_blobEntriesMutex); + if (g_unloading) return nullptr; // callers bail on null // Prune entries whose button died without an Unloaded (rare). Their // blob elements live in the RootGrid, so they must be removed @@ -656,6 +668,9 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto bg = FindChildByName(iconPanel, L"BackgroundElement"); FrameworkElement anchor = bg ? bg : iconPanel; + auto entry = FindOrCreateEntry(button); + if (!entry) return; // unloading; the uninit cleanup handles restoration + // Suppression of the native background is decided at the END, from the // same condition that shows the blob, so no failure path can leave an // active button with neither indicator. The hand-off visual's IsVisible @@ -664,9 +679,11 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // BackgroundElement, which outrank local values in XAML's precedence. // The RunningIndicator line is part of the button and renders above the // blob (which sits below the repeater in z-order), so it is suppressed - // together with the background while the blob is shown. + // together with the background while the blob is shown. Tracked per + // entry, so only state WE changed is ever touched or restored. auto runningIndicator = FindChildByName(iconPanel, L"RunningIndicator"); auto setNativeHidden = [&](bool hidden) { + if (entry->nativeHidden == hidden) return; if (bg) { try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(!hidden); @@ -677,10 +694,9 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button ElementCompositionPreview::GetElementVisual(runningIndicator).IsVisible(!hidden); } catch (...) {} } + entry->nativeHidden = hidden; }; - auto entry = FindOrCreateEntry(button); - // Button removal (window moved to another monitor, app closed, container // recycled) is a LIFECYCLE event, not a state change — UpdateVisualStates // never fires a final "inactive" for it. On a genuine Unloaded, tear the @@ -728,7 +744,7 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button RemoveFromParentPanel(blob); } if (btn) { - RestoreNativeVisuals(btn); + if (e->nativeHidden) RestoreNativeVisuals(btn); if (auto anchor = e->anchor.get()) { try { anchor.SizeChanged(e->sizeToken); } catch (...) {} } @@ -774,8 +790,9 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button grid = GetTaskbarRootGrid(button); if (!grid) { // Not rooted under a TaskbarFrame — e.g. buttons in the overflow - // flyout live in a separate XAML island. Leave the native - // indicator fully in charge there. + // flyout live in a separate XAML island. Hide any existing blob + // and leave the native indicator fully in charge there. + if (auto blob = entry->blobShape.get()) blob.Opacity(0.0); setNativeHidden(false); return; } @@ -796,10 +813,20 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button blobShape.Margin(winrt::Windows::UI::Xaml::ThicknessHelper::FromLengths(0, 0, -1000, -1000)); blobShape.Opacity(0.0); - InsertBlobBelowRepeater(grid, blobShape); + if (!InsertBlobBelowRepeater(grid, blobShape)) { + Wh_Log(L"TaskbarFrameRepeater not found as a direct RootGrid child"); + setNativeHidden(false); // no blob will be shown + return; // retried on the next event + } ElementCompositionPreview::SetIsTranslationEnabled(blobShape, true); - entry->blobShape = winrt::make_weak(blobShape); + { + // Written on the UI thread, resolved on the Windhawk thread + // (uninit, settings change): keep the cross-thread access to + // this weak ref defined by writing it under the entries mutex. + std::lock_guard lock(g_blobEntriesMutex); + entry->blobShape = winrt::make_weak(blobShape); + } entry->bound = false; entry->boundAdjX = -1e9f; entry->boundYBase = -1e9f; @@ -828,8 +855,11 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button auto parentGrid = parent ? parent.try_as() : nullptr; if (parentGrid != grid) { RemoveFromParentPanel(blobShape); - InsertBlobBelowRepeater(grid, blobShape); entry->bound = false; + if (!InsertBlobBelowRepeater(grid, blobShape)) { + setNativeHidden(false); // no blob will be shown + return; // unparented renders nothing; retried on the next event + } } } @@ -1027,11 +1057,16 @@ BOOL Wh_ModInit() { void Wh_ModBeforeUninit() { Wh_Log(L"Uninitializing Taskbar Blob Shape Mod (Before)"); - g_unloading = true; std::vector> localEntries; { + // The flag is set under the entries mutex, and FindOrCreateEntry + // checks it under the same lock: a callback that raced past an + // earlier g_unloading check can no longer create an entry AFTER this + // snapshot — such an entry would attach handlers that cleanup() + // never revokes, firing into an unloaded DLL. std::lock_guard lock(g_blobEntriesMutex); + g_unloading = true; localEntries = g_blobEntries; g_blobEntries.clear(); } @@ -1041,8 +1076,15 @@ void Wh_ModBeforeUninit() { auto pending = std::make_shared>((int)localEntries.size()); for (auto& entry : localEntries) { - auto blobShape = entry->blobShape.get(); - auto btn = entry->button.get(); + winrt::Windows::UI::Xaml::Shapes::Path blobShape{nullptr}; + winrt::Windows::UI::Xaml::FrameworkElement btn{nullptr}; + { + // blobShape is written on the UI thread; resolve these weak refs + // under the entries mutex to keep the cross-thread read defined. + std::lock_guard lock(g_blobEntriesMutex); + blobShape = entry->blobShape.get(); + btn = entry->button.get(); + } auto cleanup = [entry, blobShape]() { try { @@ -1062,7 +1104,7 @@ void Wh_ModBeforeUninit() { if (entry->loadedAttached) { try { btn.Loaded(entry->loadedToken); } catch (...) {} } - RestoreNativeVisuals(btn); + if (entry->nativeHidden) RestoreNativeVisuals(btn); } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; @@ -1109,8 +1151,14 @@ void Wh_ModSettingsChanged() { localEntries = g_blobEntries; } for (auto& entry : localEntries) { - auto blobShape = entry->blobShape.get(); - auto btn = entry->button.get(); + winrt::Windows::UI::Xaml::Shapes::Path blobShape{nullptr}; + winrt::Windows::UI::Xaml::FrameworkElement btn{nullptr}; + { + // Same cross-thread rule as the uninit path. + std::lock_guard lock(g_blobEntriesMutex); + blobShape = entry->blobShape.get(); + btn = entry->button.get(); + } auto dispatcher = blobShape ? blobShape.Dispatcher() : (btn ? btn.Dispatcher() : nullptr); if (!dispatcher) continue; From ce30ac3fd8456deed54c35ed59485d985a20afd2 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:58:56 +0300 Subject: [PATCH 14/16] white flash out of focus suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split restore: on deactivation, restore the RunningIndicator immediately (so the running dash appears correctly), but bring BackgroundElement back on a short one-shot delay (~400 ms) that outlives the transition storyboards — and cancel the pending restore if the button re-activates first, so rapid task switching never flashes. This is the legitimate use of a timer (an actual time-based phenomenon — animation duration), and it follows the discipline we established: one-shot, never re-arms itself, stopped on every teardown path. --- mods/taskbar-blob-shape.wh.cpp | 91 ++++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 6e2a3b2473..c995aa9c93 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -83,6 +83,7 @@ outward with concave (outside) corner radii, ending in a flat top edge: #include #include #include +#include #include #include #include @@ -128,8 +129,14 @@ struct BlobEntry { // Whether WE hid the native indicator visuals for this button, so the // restore paths only touch what the mod actually changed (and never - // override a Visibility the shell might set itself). - bool nativeHidden = false; + // override a Visibility the shell might set itself). The two elements + // are tracked separately because they restore differently: the + // RunningIndicator comes back immediately on deactivation, while the + // BackgroundElement comes back on a short delay (see ScheduleBgRestore) + // so it isn't revealed mid-storyboard as a white flash. + bool bgHidden = false; + bool indicatorHidden = false; + winrt::Windows::UI::Xaml::DispatcherTimer restoreTimer{nullptr}; // Whether the blob's Translation is glued to its button's offset chain, // plus the X adjustment and taskbar-top Y anchor it was bound with @@ -549,6 +556,36 @@ void RefreshBlob(winrt::Windows::UI::Xaml::FrameworkElement const& button, const EnsureBlobOnButton(button, iconPanel, isActive, localSettings); } +// Restores the BackgroundElement's visual a beat AFTER deactivation instead +// of immediately: at the deactivation moment the shell's press-release and +// state-transition storyboards are still animating it (often through their +// brightest keyframes, with the pointer still over the button), so revealing +// it in the same frame the blob hides flashes the native highlight. One-shot, +// never re-arms itself, and canceled if the button re-activates first — +// rapid task switching never lets the highlight through. +void ScheduleBgRestore(std::shared_ptr const& entry) { + if (!entry->restoreTimer) { + auto timer = winrt::Windows::UI::Xaml::DispatcherTimer(); + timer.Interval(winrt::Windows::Foundation::TimeSpan(std::chrono::milliseconds(400))); + std::weak_ptr weakEntry = entry; + timer.Tick([weakEntry](auto const&, auto const&) { + auto e = weakEntry.lock(); + if (!e) return; + if (e->restoreTimer) e->restoreTimer.Stop(); // one-shot + if (g_unloading || !e->bgHidden) return; + // The anchor tracks the most recently hidden BackgroundElement. + if (auto anchor = e->anchor.get()) { + try { + ElementCompositionPreview::GetElementVisual(anchor).IsVisible(true); + } catch (...) {} + } + e->bgHidden = false; + }); + entry->restoreTimer = timer; + } + entry->restoreTimer.Start(); +} + std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::FrameworkElement const& button) { std::vector orphans; std::shared_ptr result; @@ -683,18 +720,36 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // entry, so only state WE changed is ever touched or restored. auto runningIndicator = FindChildByName(iconPanel, L"RunningIndicator"); auto setNativeHidden = [&](bool hidden) { - if (entry->nativeHidden == hidden) return; - if (bg) { - try { - ElementCompositionPreview::GetElementVisual(bg).IsVisible(!hidden); - } catch (...) {} - } - if (runningIndicator) { - try { - ElementCompositionPreview::GetElementVisual(runningIndicator).IsVisible(!hidden); - } catch (...) {} + if (hidden) { + // Cancel any pending delayed restore: re-activation while it's + // in flight must keep the background suppressed. + if (entry->restoreTimer) { + try { entry->restoreTimer.Stop(); } catch (...) {} + } + if (bg && !entry->bgHidden) { + try { + ElementCompositionPreview::GetElementVisual(bg).IsVisible(false); + entry->bgHidden = true; + } catch (...) {} + } + if (runningIndicator && !entry->indicatorHidden) { + try { + ElementCompositionPreview::GetElementVisual(runningIndicator).IsVisible(false); + entry->indicatorHidden = true; + } catch (...) {} + } + } else { + // The running dash must reflect state immediately; the + // background follows on a delay so the deactivation storyboards + // finish out of sight (no white highlight flash). + if (runningIndicator && entry->indicatorHidden) { + try { + ElementCompositionPreview::GetElementVisual(runningIndicator).IsVisible(true); + entry->indicatorHidden = false; + } catch (...) {} + } + if (entry->bgHidden) ScheduleBgRestore(entry); } - entry->nativeHidden = hidden; }; // Button removal (window moved to another monitor, app closed, container @@ -744,7 +799,10 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button RemoveFromParentPanel(blob); } if (btn) { - if (e->nativeHidden) RestoreNativeVisuals(btn); + if (e->restoreTimer) { + try { e->restoreTimer.Stop(); } catch (...) {} + } + if (e->bgHidden || e->indicatorHidden) RestoreNativeVisuals(btn); if (auto anchor = e->anchor.get()) { try { anchor.SizeChanged(e->sizeToken); } catch (...) {} } @@ -1104,7 +1162,10 @@ void Wh_ModBeforeUninit() { if (entry->loadedAttached) { try { btn.Loaded(entry->loadedToken); } catch (...) {} } - if (entry->nativeHidden) RestoreNativeVisuals(btn); + if (entry->restoreTimer) { + try { entry->restoreTimer.Stop(); } catch (...) {} + } + if (entry->bgHidden || entry->indicatorHidden) RestoreNativeVisuals(btn); } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } }; From 92c211051f1a6871ec44bfeab89c914e29a03d61 Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:53:39 +0300 Subject: [PATCH 15/16] update ScheduleBgRestore TimeSpan 400=>100 --- mods/taskbar-blob-shape.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index c995aa9c93..2eaaff34fb 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -566,7 +566,7 @@ void RefreshBlob(winrt::Windows::UI::Xaml::FrameworkElement const& button, const void ScheduleBgRestore(std::shared_ptr const& entry) { if (!entry->restoreTimer) { auto timer = winrt::Windows::UI::Xaml::DispatcherTimer(); - timer.Interval(winrt::Windows::Foundation::TimeSpan(std::chrono::milliseconds(400))); + timer.Interval(winrt::Windows::Foundation::TimeSpan(std::chrono::milliseconds(100))); std::weak_ptr weakEntry = entry; timer.Tick([weakEntry](auto const&, auto const&) { auto e = weakEntry.lock(); From bfd6590c7dbd49bb318c946474abcc033ba0657e Mon Sep 17 00:00:00 2001 From: Deen <24626517+Deen-0x@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:29:48 +0300 Subject: [PATCH 16/16] address review notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 1 — shutdown-safe container. g_blobEntries is now [[clang::no_destroy]] std::optional>{std::in_place}, all eight accesses converted to ->, and Wh_ModBeforeUninit does std::move(*g_blobEntries) + reset() instead of copy + clear(). Engaged checks added everywhere a disengaged optional could be reached: the Unloaded handler's erase (under the lock, as the reviewer specified — its g_unloading check is indeed outside), FindOrCreateEntry (folded into the existing unloading bail), and Wh_ModSettingsChanged. The reviewer's framing is exactly right: my earlier "plain global is safe" analysis was true of what BlobEntry contained then, and adding the DispatcherTimer silently invalidated it — thread-affine members change the container's shutdown class. Item 2 — the timer can no longer outlive its teardown. All three parts: Tick self-stops via the sender (sender.try_as().Stop()) before touching the entry — so even a timer whose entry is gone goes quiet after exactly one more tick, closing the "keeps ticking into an unloaded image" path. This was the sharpest catch in the review: my if (!e) return; sat before the Stop(), making the guard itself the leak. Stop hoisted out of the if (btn) branches in both the Unloaded teardown and the uninit cleanup — precisely the dead-button case where the timer is most likely armed. The prune loop now collects whole entries instead of just blobs and stops each orphan's timer on its blob's dispatcher when one exists, best-effort inline otherwise (with the sender self-stop as the backstop for a failed cross-thread Stop). Release, not just stop: restoreTimer = nullptr at all five sites, so an entry whose last shared_ptr drops on the Windhawk thread carries no strong XAML reference across threads. --- mods/taskbar-blob-shape.wh.cpp | 226 ++++++++++++++++++++++++++------- 1 file changed, 180 insertions(+), 46 deletions(-) diff --git a/mods/taskbar-blob-shape.wh.cpp b/mods/taskbar-blob-shape.wh.cpp index 2eaaff34fb..4b14c001a3 100644 --- a/mods/taskbar-blob-shape.wh.cpp +++ b/mods/taskbar-blob-shape.wh.cpp @@ -53,12 +53,12 @@ outward with concave (outside) corner radii, ending in a flat top edge: - Margins: '0, 0, 0, 0' $name: Custom blob shape margin (Left, Top, Right, Bottom) $description: Offsets the blob shape like insets - Left/Top push it right/down, Right/Bottom push it left/up (e.g. '0, 4, 0, 0' pushes it down 4px). Vertical offsets are measured from the top of the taskbar. Accepts 1, 2 (horizontal, vertical), or 4 values. Leave empty to disable. - - BottomRadius: '4' - $name: Bottom corner radius - $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). - TopRadius: '8' $name: Top corner radius $description: The radius of the concave top flare corners. The blob shape extends upward and sideways by this amount. Set to 0 to disable the flare. + - BottomRadius: '4' + $name: Bottom corner radius + $description: The radius of the convex bottom corners of the blob shape (e.g., 4.0). $name: Blob Shape Settings - Colors: - BgOpacity: '1.0, 1.0' @@ -120,6 +120,15 @@ struct BlobEntry { winrt::weak_ref blobShape; winrt::weak_ref grid; winrt::weak_ref anchor; + + // Cached per-button element lookups, re-resolved when expired or + // detached, so hover/press events don't re-walk the subtree. bgElement + // is also the dedicated "element whose visual we hid" — decoupled from + // anchor (the sizing element), so a re-templated BackgroundElement can + // never leave the old one invisible after the delayed restore. + winrt::weak_ref iconPanel; + winrt::weak_ref bgElement; + winrt::weak_ref indicatorElement; winrt::event_token sizeToken{}; winrt::event_token unloadToken{}; winrt::event_token loadedToken{}; @@ -151,7 +160,18 @@ struct BlobEntry { std::vector lastColors; }; std::mutex g_blobEntriesMutex; -std::vector> g_blobEntries; +// BlobEntry holds a DispatcherTimer — a thread-affine XAML object — so this +// container must never reach the CRT's global destructors: Wh_ModUninit does +// not run when explorer.exe itself terminates, and destroying the vector on +// the shutdown thread would release the timers off the UI thread after XAML +// teardown. [[clang::no_destroy]] suppresses the destructor; the explicit +// reset() in Wh_ModBeforeUninit remains the release path for mod unload. +[[clang::no_destroy]] std::optional>> + g_blobEntries{std::in_place}; +// Taskbar grids whose pre-existing buttons were already swept (see +// SweepExistingButtons). Weak refs only, so plain-global destruction at +// process shutdown is safe. Guarded by g_blobEntriesMutex. +std::vector> g_sweptGrids; std::atomic g_unloading{false}; std::atomic g_taskbarViewDllLoaded{false}; @@ -538,22 +558,34 @@ bool InsertBlobBelowRepeater(Grid const& grid, winrt::Windows::UI::Xaml::Shapes: using TaskListButton_UpdateVisualStates_t = void(WINAPI*)(void*); TaskListButton_UpdateVisualStates_t TaskListButton_UpdateVisualStates_Original; -void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings); +struct BlobEntry; +std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::FrameworkElement const& button); +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, std::shared_ptr const& entry, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings); // The single entry point for every trigger (hook, SizeChanged, Loaded, stale -// Unloaded, theme change, settings change): resolves the button's IconPanel -// once, reads the running indicator state from it, and applies the blob — -// instead of each path doing its own recursive tree walks. +// Unloaded, theme change, settings change, sibling sweep): resolves the +// entry and its cached IconPanel, reads the running indicator state, and +// applies the blob. The recursive tree walk only runs when the cached +// element has expired or been detached. void RefreshBlob(winrt::Windows::UI::Xaml::FrameworkElement const& button, const Settings& localSettings) { - auto iconPanel = FindChildByName(button, L"IconPanel"); + auto entry = FindOrCreateEntry(button); + if (!entry) return; // unloading + + auto iconPanel = entry->iconPanel.get(); + if (!iconPanel || !VisualTreeHelper::GetParent(iconPanel)) { + iconPanel = FindChildByName(button, L"IconPanel"); + entry->iconPanel = iconPanel ? winrt::make_weak(iconPanel) + : winrt::weak_ref{}; + } if (!iconPanel) return; + bool isActive = false; try { auto grp = GetVisualStateGroup(iconPanel, L"RunningIndicatorStates"); auto st = grp ? grp.CurrentState() : nullptr; isActive = st && st.Name() == L"ActiveRunningIndicator"; } catch (...) {} - EnsureBlobOnButton(button, iconPanel, isActive, localSettings); + EnsureBlobOnButton(button, entry, iconPanel, isActive, localSettings); } // Restores the BackgroundElement's visual a beat AFTER deactivation instead @@ -568,40 +600,91 @@ void ScheduleBgRestore(std::shared_ptr const& entry) { auto timer = winrt::Windows::UI::Xaml::DispatcherTimer(); timer.Interval(winrt::Windows::Foundation::TimeSpan(std::chrono::milliseconds(100))); std::weak_ptr weakEntry = entry; - timer.Tick([weakEntry](auto const&, auto const&) { + timer.Tick([weakEntry](winrt::Windows::Foundation::IInspectable const& sender, auto const&) { + // Self-stopping via the sender: the timer must go quiet even if + // its entry is already gone — a started DispatcherTimer is kept + // alive by the dispatcher, and a tick after mod unload would + // land in an unloaded DLL. + if (auto t = sender.try_as()) { + try { t.Stop(); } catch (...) {} + } auto e = weakEntry.lock(); - if (!e) return; - if (e->restoreTimer) e->restoreTimer.Stop(); // one-shot - if (g_unloading || !e->bgHidden) return; - // The anchor tracks the most recently hidden BackgroundElement. - if (auto anchor = e->anchor.get()) { + if (!e || g_unloading || !e->bgHidden) return; + // bgElement is the element whose visual was actually hidden. + if (auto bgEl = e->bgElement.get()) { try { - ElementCompositionPreview::GetElementVisual(anchor).IsVisible(true); + ElementCompositionPreview::GetElementVisual(bgEl).IsVisible(true); } catch (...) {} } e->bgHidden = false; }); entry->restoreTimer = timer; } - entry->restoreTimer.Start(); + // Start() on a running timer restarts its countdown; repeated + // inactive-state events (hover, press) must not push the restore back, + // so the delay stays fixed from the deactivation that armed it. + if (!entry->restoreTimer.IsEnabled()) entry->restoreTimer.Start(); +} + +// One-time per taskbar grid: applies blobs to the buttons that already +// existed when this taskbar was first seen. Buttons only get a blob when an +// event fires for them, so after a mid-session enable the active button +// would stay bare until it next changes state — instead, the first event +// from ANY button on a taskbar sweeps its realized siblings (the repeater's +// visual children). Marked BEFORE sweeping, so the re-entrant RefreshBlob +// calls can't recurse. +void SweepExistingButtons(Grid const& grid, const Settings& localSettings) { + { + std::lock_guard lock(g_blobEntriesMutex); + for (auto it = g_sweptGrids.begin(); it != g_sweptGrids.end(); ) { + auto g = it->get(); + if (!g) { it = g_sweptGrids.erase(it); continue; } + if (g == grid) return; // already swept + ++it; + } + g_sweptGrids.push_back(winrt::make_weak(grid)); + } + + try { + FrameworkElement repeater = nullptr; + auto children = grid.Children(); + for (uint32_t i = 0; i < children.Size(); i++) { + auto child = children.GetAt(i).try_as(); + if (child && child.Name() == L"TaskbarFrameRepeater") { + repeater = child; + break; + } + } + if (!repeater) return; + + int count = VisualTreeHelper::GetChildrenCount(repeater); + for (int i = 0; i < count; i++) { + auto child = VisualTreeHelper::GetChild(repeater, i).try_as(); + if (child && winrt::get_class_name(child) == L"Taskbar.TaskListButton") { + try { RefreshBlob(child, localSettings); } catch (...) {} + } + } + } catch (...) {} } std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::FrameworkElement const& button) { - std::vector orphans; + std::vector> orphans; std::shared_ptr result; { std::lock_guard lock(g_blobEntriesMutex); - if (g_unloading) return nullptr; // callers bail on null + if (g_unloading || !g_blobEntries) return nullptr; // callers bail on null // Prune entries whose button died without an Unloaded (rare). Their // blob elements live in the RootGrid, so they must be removed - // explicitly. This runs on every lookup — the list is small since + // explicitly — and an armed restore timer must be stopped, since a + // started DispatcherTimer is kept alive by its dispatcher, not by + // the entry. This runs on every lookup — the list is small since // Unloaded drops entries eagerly. - for (auto it = g_blobEntries.begin(); it != g_blobEntries.end(); ) { + for (auto it = g_blobEntries->begin(); it != g_blobEntries->end(); ) { auto btn = (*it)->button.get(); if (!btn) { - if (auto blob = (*it)->blobShape.get()) orphans.push_back(blob); - it = g_blobEntries.erase(it); + orphans.push_back(*it); + it = g_blobEntries->erase(it); continue; } if (btn == button) result = *it; @@ -610,18 +693,31 @@ std::shared_ptr FindOrCreateEntry(winrt::Windows::UI::Xaml::Framework if (!result) { result = std::make_shared(); result->button = winrt::make_weak(button); - g_blobEntries.push_back(result); + g_blobEntries->push_back(result); } } - for (auto& blob : orphans) { - if (auto dispatcher = blob.Dispatcher()) { - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [blob]() { + for (auto& orphan : orphans) { + auto blob = orphan->blobShape.get(); + auto dispatcher = blob ? blob.Dispatcher() : nullptr; + if (dispatcher) { + // Stop the timer on its own UI thread, then remove the blob. + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::High, [orphan, blob]() { + if (orphan->restoreTimer) { + try { orphan->restoreTimer.Stop(); } catch (...) {} + orphan->restoreTimer = nullptr; + } try { ElementCompositionPreview::GetElementVisual(blob).Properties().StopAnimation(L"Translation"); } catch (...) {} RemoveFromParentPanel(blob); }); + } else if (orphan->restoreTimer) { + // No dispatcher handle (island likely gone): best-effort inline + // stop. The tick also self-stops via its sender, so a failed + // cross-thread Stop() still goes quiet on the next tick. + try { orphan->restoreTimer.Stop(); } catch (...) {} + orphan->restoreTimer = nullptr; } } return result; @@ -701,13 +797,15 @@ bool BindBlobExpression( // so the flare tips render fully — and glued to its button with a one-time // composition expression. Activation is purely an opacity toggle on the // button's own blob. -void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings) { - auto bg = FindChildByName(iconPanel, L"BackgroundElement"); +void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button, std::shared_ptr const& entry, winrt::Windows::UI::Xaml::FrameworkElement const& iconPanel, bool isActive, const Settings& localSettings) { + auto bg = entry->bgElement.get(); + if (!bg || !VisualTreeHelper::GetParent(bg)) { + bg = FindChildByName(iconPanel, L"BackgroundElement"); + entry->bgElement = bg ? winrt::make_weak(bg) + : winrt::weak_ref{}; + } FrameworkElement anchor = bg ? bg : iconPanel; - auto entry = FindOrCreateEntry(button); - if (!entry) return; // unloading; the uninit cleanup handles restoration - // Suppression of the native background is decided at the END, from the // same condition that shows the blob, so no failure path can leave an // active button with neither indicator. The hand-off visual's IsVisible @@ -718,7 +816,12 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button // blob (which sits below the repeater in z-order), so it is suppressed // together with the background while the blob is shown. Tracked per // entry, so only state WE changed is ever touched or restored. - auto runningIndicator = FindChildByName(iconPanel, L"RunningIndicator"); + auto runningIndicator = entry->indicatorElement.get(); + if (!runningIndicator || !VisualTreeHelper::GetParent(runningIndicator)) { + runningIndicator = FindChildByName(iconPanel, L"RunningIndicator"); + entry->indicatorElement = runningIndicator ? winrt::make_weak(runningIndicator) + : winrt::weak_ref{}; + } auto setNativeHidden = [&](bool hidden) { if (hidden) { // Cancel any pending delayed restore: re-activation while it's @@ -798,10 +901,11 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button } catch (...) {} RemoveFromParentPanel(blob); } + if (e->restoreTimer) { + try { e->restoreTimer.Stop(); } catch (...) {} + e->restoreTimer = nullptr; + } if (btn) { - if (e->restoreTimer) { - try { e->restoreTimer.Stop(); } catch (...) {} - } if (e->bgHidden || e->indicatorHidden) RestoreNativeVisuals(btn); if (auto anchor = e->anchor.get()) { try { anchor.SizeChanged(e->sizeToken); } catch (...) {} @@ -812,10 +916,11 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button } } std::lock_guard lock(g_blobEntriesMutex); - g_blobEntries.erase( - std::remove_if(g_blobEntries.begin(), g_blobEntries.end(), + if (!g_blobEntries) return; // reset by Wh_ModBeforeUninit + g_blobEntries->erase( + std::remove_if(g_blobEntries->begin(), g_blobEntries->end(), [&](auto& x) { return x == e; }), - g_blobEntries.end()); + g_blobEntries->end()); }); } @@ -855,6 +960,8 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button return; } entry->grid = winrt::make_weak(grid); + // First contact with this taskbar: bring its pre-existing buttons up. + SweepExistingButtons(grid, localSettings); } auto blobShape = entry->blobShape.get(); @@ -1027,6 +1134,17 @@ void EnsureBlobOnButton(winrt::Windows::UI::Xaml::FrameworkElement const& button bool show = isActive && entry->bound; blobShape.Opacity(show ? 1.0 : 0.0); setNativeHidden(show); + + // Idle blobs don't evaluate: one live expression per button would keep + // ~20 per-frame evaluations running on the render thread for shapes at + // Opacity(0). Stop on deactivation; the bound flag already drives the + // cheap rebind on the next activation. + if (!isActive && entry->bound) { + try { + ElementCompositionPreview::GetElementVisual(blobShape).Properties().StopAnimation(L"Translation"); + } catch (...) {} + entry->bound = false; + } } void WINAPI TaskListButton_UpdateVisualStates_Hook(void* pThis) { @@ -1125,8 +1243,12 @@ void Wh_ModBeforeUninit() { // never revokes, firing into an unloaded DLL. std::lock_guard lock(g_blobEntriesMutex); g_unloading = true; - localEntries = g_blobEntries; - g_blobEntries.clear(); + if (g_blobEntries) { + localEntries = std::move(*g_blobEntries); + } + // reset() rather than clear(): the container of thread-affine + // timers must never survive to the CRT's global destructors. + g_blobEntries.reset(); } if (localEntries.empty()) return; @@ -1155,6 +1277,14 @@ void Wh_ModBeforeUninit() { vis.Properties().StopAnimation(L"Translation"); RemoveFromParentPanel(blobShape); } + // Stop AND release the timer here, on the UI thread: the + // entry's last shared_ptr may be released on the Windhawk + // thread, and it must not carry a strong XAML reference + // across threads when that happens. + if (entry->restoreTimer) { + try { entry->restoreTimer.Stop(); } catch (...) {} + entry->restoreTimer = nullptr; + } if (auto btn = entry->button.get()) { if (entry->unloadAttached) { try { btn.Unloaded(entry->unloadToken); } catch (...) {} @@ -1162,9 +1292,6 @@ void Wh_ModBeforeUninit() { if (entry->loadedAttached) { try { btn.Loaded(entry->loadedToken); } catch (...) {} } - if (entry->restoreTimer) { - try { entry->restoreTimer.Stop(); } catch (...) {} - } if (entry->bgHidden || entry->indicatorHidden) RestoreNativeVisuals(btn); } } catch (...) { Wh_Log(L"Exception during blob shape cleanup"); } @@ -1189,6 +1316,13 @@ void Wh_ModBeforeUninit() { }); } } else { + // No dispatcher at all (island gone): best-effort inline stop so + // the entry drops its timer reference before ~BlobEntry runs on + // this thread. + if (entry->restoreTimer) { + try { entry->restoreTimer.Stop(); } catch (...) {} + entry->restoreTimer = nullptr; + } if (pending->fetch_sub(1) == 1 && eventLifetime.get()) SetEvent(eventLifetime.get()); } } @@ -1209,7 +1343,7 @@ void Wh_ModSettingsChanged() { std::vector> localEntries; { std::lock_guard lock(g_blobEntriesMutex); - localEntries = g_blobEntries; + if (g_blobEntries) localEntries = *g_blobEntries; } for (auto& entry : localEntries) { winrt::Windows::UI::Xaml::Shapes::Path blobShape{nullptr};