Add Taskbar Blob Shape mod - #4941
Conversation
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.
… its own BackgroundElement | Cleanup restores both: IsVisible(true) 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.
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.
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. The overall design (one 1. Remove the VEH / This is the most serious item.
The mod this is derived from already does this in six lines — taskbar-elastic-pill.wh.cpp#L734-L743, same as taskbar-labels.wh.cpp#L1346-L1350: FrameworkElement GetFrameworkElementFromNative(void* pThis) {
if (!pThis) return nullptr;
try {
void* iUnknownPtr = (void**)pThis + 3;
winrt::Windows::Foundation::IUnknown iUnknown;
winrt::copy_from_abi(iUnknown, iUnknownPtr);
return iUnknown.try_as<FrameworkElement>();
} catch (...) {
return nullptr;
}
}Dropping the probe machinery removes ~80 lines and all of the above risk. (If it was an AI-generated "make it robust" addition rather than a deliberate choice, that's another reason to remove it.) 2. Four separate problems in that block:
Hook inline in the loader hook, like the mod you derived from (taskbar-elastic-pill.wh.cpp#L1889-L1902): HMODULE WINAPI LoadLibraryExW_Hook(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) {
HMODULE module = LoadLibraryExW_Original(lpLibFileName, hFile, dwFlags);
if (module && !g_taskbarViewDllLoaded &&
GetTaskbarViewModuleHandle() == module && !g_taskbarViewDllLoaded.exchange(true)) {
Wh_Log(L"Taskbar View DLL loaded: %s", lpLibFileName);
if (HookTaskbarViewDllSymbols(module)) Wh_ApplyHookOperations();
}
return module;
}3. The native
Drive both the blob opacity and the suppression from one condition, at the end, and restore the native visual on the early-outs: auto setShown = [&](bool show) {
if (bg) {
try { ElementCompositionPreview::GetElementVisual(bg).IsVisible(!show); } catch (...) {}
}
};
...
if (!grid) { setShown(false); return; } // native indicator comes back
...
bool show = isActive && entry->bound;
blobShape.Opacity(show ? 1.0 : 0.0);
setShown(show);4. Stale blobs and their expression animations accumulate. The 5. Overlap with Taskbar Elastic WinUI Pill. Both mods replace the Windows 11 active-item indicator with a custom parametric shape, and this one reuses that mod's structure and settings vocabulary almost verbatim ( 6. README: add a screenshot and the attribution. This is a purely visual mod and the README has no image — add one of the images from the PR body (only 7.
Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations about the feature behavior itself.
|
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.
1. Global container — plain std::vector<std::shared_ptr<BlobEntry>> 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 — <string>, <string_view>, and <cwctype> (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.
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.
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. Thanks for working through the previous round — the VEH probing, the deferred-hook thread, the suppression logic, the 1. Buttons that never get a blob keep their
auto dispatcher = blobShape ? blobShape.Dispatcher() : nullptr;
if (dispatcher) { /* ... cleanup ... */ }
else { /* just decrements `pending` */ }So for those entries This isn't hypothetical: the taskbar overflow flyout hosts
auto blobShape = entry->blobShape.get();
auto btn = entry->button.get();
...
auto dispatcher = blobShape ? blobShape.Dispatcher()
: (btn ? btn.Dispatcher() : nullptr);taskbar-elastic-pill.wh.cpp#L2044-L2076 does exactly this for its button event tokens (
2. Overlap with Taskbar Elastic WinUI Pill (unresolved from the previous round). The attribution in the README is appreciated, but the underlying question is still open: both mods replace the Windows 11 active-item indicator with a custom parametric shape, and this one shares that mod's structure and settings vocabulary ( Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
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.
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.
|
Regarding Point 2: The elastic pill is a convex rounded rectangle sliding behind the active button. The blob is a tab silhouette with concave top corners and a flat top edge anchored flush to the taskbar's top — it reads as the button merging into the taskbar surface, not as an indicator behind it. No parameter values turn one into the other: a Border's CornerRadius can't produce concave corners, and the flares extend beyond the button's bounds. That geometry dictates the architecture: a Path with programmatic geometry instead of a styled Border; hosted in RootGrid above the task list's clip region (button-hosted shapes get their flares clipped — verified); one static shape per button toggled by indicator state instead of one shared shape animating between them, each glued to its button with an X-only composition expression while Y is structurally anchored to the taskbar top. Merging this into the pill wouldn't be a shape enum — it's a second rendering element, hosting model, and lifecycle model behind a mode switch, sharing little beyond the hook. Regarding functionality notes:
|
|
/ai-review |
|
New commits were pushed, so this pull request left the human review queue and is back to waiting-for-author. Comment |
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.
|
/ai-review |
|
This pull request has already had 2 AI reviews in the last 24 hours, which is the limit, so no review was posted this time. Comment |
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. The mod is in good shape overall — the lifecycle handling, the unload barrier, the 1. Fix — suppress the automatic destructor and keep the explicit release: [[clang::no_destroy]] std::optional<std::vector<std::shared_ptr<BlobEntry>>>
g_blobEntries{std::in_place};with the accesses becoming 2. The restore timer is not stopped on three of its teardown paths. In both teardown paths the Fix, three parts:
3. Overlap with taskbar-elastic-pill. The attribution in the README is appreciated, but the relationship is closer than "based on": the same The maintainer's standing preference is to extend an existing mod rather than merge a close variant, since duplicates fragment the catalog and make it harder for users to pick. Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
Next steps:
See the review process for details. |
Item 1 — shutdown-safe container. g_blobEntries is now [[clang::no_destroy]] std::optional<std::vector<...>>{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<DispatcherTimer>().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.
|
Regarding the relationship to Taskbar Elastic WinUI Pill. This began as a fork of that lineage, which is why the hook, helpers, and settings vocabulary are shared — kept deliberately so users find familiar knobs. The mods diverge at the geometry, and the geometry forced a different architecture. The pill is a convex rounded Border that slides between buttons; the blob is a tab silhouette with concave top flares and a flat top edge anchored to the taskbar's top — no CornerRadius values produce it, and the flares extend beyond the button's bounds. That requires a Path with programmatic geometry, hosting in RootGrid above the task list's clip region (button-hosted shapes get their flares clipped — verified), one static per-button shape with full lifecycle tracking instead of one animated shared element, and no animation surface at all. Merging it into the pill would mean a second rendering element, hosting model, positioning model, and lifecycle model behind a mode switch — a maintenance burden on Lockframe's mod larger than a standalone mod that shares its vocabulary. That said, I defer to the maintainer and to Lockframe: if a Shape: pill | blob option upstream is preferred, I'll contribute this there instead. The mods should not be enabled together (both suppress the native indicator), which I've noted in the README — happy to also add a mutual note to the pill's README if Lockframe agrees. |
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. The shutdown-safety and timer-lifetime items from the last round are properly fixed — 1. The README doesn't carry the conflict warning. In the PR discussion you wrote that the mods "should not be enabled together (both suppress the native indicator), which I've noted in the README" — but the README block at head ( On the consolidation question itself (fold into taskbar-elastic-pill behind a Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
Next steps:
See the review process for details. |
|
/ready-for-reviewer |
Taskbar Blob Shape
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.
Attribution
Use case
Features
automatches the button's background element), and margin offsets.light | darkvalues, per-theme opacity multipliers, and automatic fallback to the system accent color when no custom color is set.Implementation notes
TaskListButton::UpdateVisualStates(Taskbar.View.dll) to observe running indicator state changes; activation is a pure per-button opacity toggle.Pathelement, hosted in the taskbar'sRootGrid(above the task list's clipping region so the flare tips render fully) and glued to its button with a one-time compositionExpressionAnimationon the button's visual offset chain — the shape tracks reordering, reflow, and taskbar animations on the render thread with no per-frame UI-thread work.Unloadedhandler per button hides its shape and invalidates the binding when the button leaves the tree (window moved to another monitor, app closed, container recycled), and reused containers re-resolve their hosting grid, re-parent the shape if needed, and rebind — shapes never outlive or detach from their buttons.BackgroundElementis suppressed via its hand-off visual'sIsVisibleflag rather than a localOpacityvalue, so visual-state transition storyboards (which outrank local values in XAML property precedence) can't bring it back on top of the shape.SizeChangedsubscription per button — no timers, no polling.Tested on
Mod authorship
This mod was created by: Claude