From 624066aa86b116b98c6bab54edf42154c8ebbed2 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 15:05:28 +0800 Subject: [PATCH 1/2] Keep combined taskbar previews in tab order for Neovide and Alacritty These windows join their group, leave it and join again as they start. The returning preview could be registered by Explorer after the order that places it and showed first. Previews are now unregistered before they are destroyed, and a changed order is sent again once settled. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 27 +++++++++++++++++++++--- tests/GroupLifecycle.fsx | 21 ++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 9eec97e..44f6154 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -209,8 +209,18 @@ type TaskBarButton(info) as this = taskbar.AddTab(hwnd) let registerTab(hwndTab) = taskbar.RegisterTab(hwndTab, this.window.hwnd) - let setTabOrder(hwnd, hwndInsertBefore) = - taskbar.SetTabOrder(hwnd, hwndInsertBefore) + /// Each preview goes to the end in turn, which leaves them in the strip's order. + let sendTabOrder() = + infoCell.value |> Option.iter(fun info -> + info.tabs.map(fst).choose(tabWindowsCell.value.tryFind).iter <| fun (tabWindow:TaskbarTab) -> + taskbar.SetTabOrder(tabWindow.window.hwnd, IntPtr.Zero)) + /// A new window can leave its group and join again within moments (Neovide and Alacritty + /// change their title as they start). Explorer can take the new preview's registration after + /// the order that places it, and then shows it first, so the order is sent again once settled. + let resendTabOrder = new System.Windows.Forms.Timer(Interval=500) + do resendTabOrder.Tick.Add(fun _ -> + resendTabOrder.Stop() + sendTabOrder()) let _window = lazy( let config() : TbButton = infoCell.value.Value @@ -322,8 +332,17 @@ type TaskBarButton(info) as this = let tabOrder = tabOrder info if tabOrder.list <> prevTabOrder.list then - tabOrder.iter <| fun tabWindow -> setTabOrder(tabWindow.window.hwnd, IntPtr.Zero) + sendTabOrder() + resendTabOrder.Stop() + resendTabOrder.Start() + /// The order was just changed and is to be sent again. + member this.isResendingTabOrder = resendTabOrder.Enabled + + /// Explorer keeps a destroyed preview in the group's list unless it is told the tab closed. + member this.unregisterTab(hwnd:IntPtr) = + taskbar.UnregisterTab(hwnd) + /// A group can announce a window before its tab joins the strip, so the tab may have no preview window yet. member this.tryTabWindow key = tabWindowsCell.value.tryFind(key) |> Option.map(fun tab -> tab.window) @@ -349,6 +368,7 @@ type TaskBarButton(info) as this = interface IDisposable with member this.Dispose() = + resendTabOrder.Dispose() this.window.destroy() tabWindowsCell.value.items.iter <| fun (tab, tabWindow) -> tabWindow.Dispose() @@ -393,6 +413,7 @@ and TaskbarTab(parent:TaskBarButton, config,size) = member this.window : Window = _window member this.invalidate() = this.window.dwmInvalidateIconicBitmaps() member this.Dispose() = + parent.unregisterTab(_window.hwnd) _window.destroy() type SuperBarPlugin() as this = diff --git a/tests/GroupLifecycle.fsx b/tests/GroupLifecycle.fsx index 589cf5f..c9726f1 100644 --- a/tests/GroupLifecycle.fsx +++ b/tests/GroupLifecycle.fsx @@ -134,6 +134,27 @@ let main() = onGroup combined (fun group -> for hwnd in group.windows.items.list do group.removeWindow hwnd) pumpUntil "Taskbar proxies outlived their combined taskbar button" (fun () -> not(TaskbarProxies.contains proxy.Head)) + // Neovide and Alacritty windows join, leave and join again as they start. Explorer can + // register the returning preview after the order that places it and show it first, so a + // changed order is sent again once settled. + do + let tab text : string * TbButtonTab = + text, { icon=Drawing.SystemIcons.Application; text=text; activate=ignore; close=ignore; preview=fun _ -> None } + let info texts : TbButton = + { icon=Drawing.SystemIcons.Application; text="Order"; bounds=Rect(Pt(-20000,-20000),Sz(320,200)) + activate=ignore; toggleMinimizeRestore=ignore; close=ignore; tabs=List2(texts |> List.map tab) } + let button = new TaskBarButton(info ["a";"b"]) + try + pumpUntil "First preview order was not sent again" (fun () -> not button.isResendingTabOrder) + button.update(info ["a";"b";"c"]) + button.update(info ["a";"b"]) + button.update(info ["a";"b";"c"]) + check button.isResendingTabOrder "A preview that rejoined is not ordered again" + pumpUntil "Preview order was not sent again" (fun () -> not button.isResendingTabOrder) + button.update(info ["a";"b";"c"]) + check (not button.isResendingTabOrder) "An unchanged order is sent again" + finally (button :> IDisposable).Dispose() + // The setting rebuilds open groups that follow it, keeping what their tab menu chose. api.setValue("combineIconsInTaskbar", box false) let groupsOf hwnd = desktopApi.groups.list |> List.filter(fun g -> g.windows.contains((=)hwnd)) |> List.map(fun g -> g :?> GroupInfo) From 0a2b88da9e19ad07f04985b92b96e0dd432e9ed6 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 15:24:01 +0800 Subject: [PATCH 2/2] Free a closed tab's taskbar preview with it and send Explorer only changes A preview window kept its window class and pinned procedure until its whole group closed, so groups whose tabs come and go kept collecting them. Previews are now unregistered before the group's taskbar window goes, and titles, positions and the active preview are sent only when they change. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 65 ++++++++++++++++-------- tests/GroupLifecycle.fsx | 7 +++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 44f6154..f512683 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -203,7 +203,8 @@ type TaskBarButton(info) as this = let mutable badgedIcon : TaskbarBadgedIcon option = None let infoCell = Cell.create(None) let tabWindowsCell = Cell.create(Map2()) - let windowsToDispose = Cell.create(Set2()) + /// Each window has a class and a pinned procedure of its own, freed with it. + let windows = Collections.Generic.Dictionary() let appId = Guid.NewGuid().ToString() let addTab hwnd = taskbar.AddTab(hwnd) @@ -267,6 +268,9 @@ type TaskBarButton(info) as this = do this.update info + /// How a tab's preview is found from its window. + static member key (hwnd:IntPtr) = hwnd.ToString() + member this.os = _os member this.window : Window = _window.Force() @@ -304,10 +308,13 @@ type TaskBarButton(info) as this = this.window.setIcons(badged.icon) previous |> Option.iter(fun icon -> (icon :> IDisposable).Dispose()) - this.window.move(info.bounds) - this.window.setText(info.text) + // Each change is a notice to Explorer; updates come with every title and focus change. + let unchanged (read:TbButton -> 'a) = prevInfo |> Option.exists(fun previous -> read previous = read info) + if not(unchanged(fun i -> i.bounds)) then this.window.move(info.bounds) + if not(unchanged(fun i -> i.text)) then this.window.setText(info.text) info.tabs.iter <| fun (tab, tabConfig) -> + let isNew = not(tabWindowsCell.value.contains tab) let tabWindow = findOrCreate tabWindowsCell tab <| fun() -> let config() = infoCell.value.Value.tabs.find(fst >> (=) tab) |> snd let tabWindow = TaskbarTab(this, config, fun() -> this.window.size) @@ -318,11 +325,10 @@ type TaskBarButton(info) as this = registerTab(tabWindow.window.hwnd) tabWindow - tabWindow.window.move(info.bounds) - tabWindow.window.setText(tabConfig.text) - let previousIcon = prevInfo |> Option.bind(fun previous -> - previous.tabs.tryFind(fst >> (=) tab) |> Option.map(fun (_,config) -> config.icon)) - if previousIcon |> Option.exists(fun icon -> obj.ReferenceEquals(icon,tabConfig.icon)) |> not then + let previous = prevInfo |> Option.bind(fun previous -> previous.tabs.tryFind(fst >> (=) tab)) |> Option.map snd + if isNew || not(unchanged(fun i -> i.bounds)) then tabWindow.window.move(info.bounds) + if isNew || previous |> Option.forall(fun config -> config.text<>tabConfig.text) then tabWindow.window.setText(tabConfig.text) + if isNew || previous |> Option.forall(fun config -> not(obj.ReferenceEquals(config.icon,tabConfig.icon))) then tabWindow.window.setIcons(tabConfig.icon) let prevTabOrder = @@ -363,17 +369,29 @@ type TaskBarButton(info) as this = let window = this.os.createWindow wndProc dwmWindow.style dwmWindow.exStyle TaskbarProxies.add window.hwnd - windowsToDispose.map(fun s -> s.add(window:?>IDisposable)) + windows.[window.hwnd] <- window :?> IDisposable this.os.windowFromHwnd(window.hwnd) + /// A tab's preview leaves with its tab, not with the whole group. + member this.releaseWindow(hwnd:IntPtr) = + match windows.TryGetValue hwnd with + | true,window -> + windows.Remove hwnd |> ignore + window.Dispose() + | _ -> () + + /// The windows this button holds: its own and one per tab. + member this.windowCount = windows.Count + interface IDisposable with member this.Dispose() = resendTabOrder.Dispose() - this.window.destroy() + // Previews are unregistered while the window they belong to still exists. tabWindowsCell.value.items.iter <| fun (tab, tabWindow) -> tabWindow.Dispose() tabWindowsCell.set(Map2()) - windowsToDispose.value.items.iter <| fun d -> d.Dispose() + for window in List.ofSeq windows.Values do window.Dispose() + windows.Clear() badgedIcon |> Option.iter(fun icon -> (icon :> IDisposable).Dispose()) badgedIcon <- None @@ -414,7 +432,7 @@ and TaskbarTab(parent:TaskBarButton, config,size) = member this.invalidate() = this.window.dwmInvalidateIconicBitmaps() member this.Dispose() = parent.unregisterTab(_window.hwnd) - _window.destroy() + parent.releaseWindow(_window.hwnd) type SuperBarPlugin() as this = let _os = OS() @@ -422,6 +440,8 @@ type SuperBarPlugin() as this = let _taskbar = _os.getTaskbar().Value let mutable taskbarButton = null + /// The preview last shown as active, None while the group is not in front. + let mutable activePreview : IntPtr option = None let flashingTabs = Collections.Generic.HashSet() let captures = new TabCaptures() /// A tab that comes to the front is captured once it has painted, for when it is hidden again. @@ -477,7 +497,8 @@ type SuperBarPlugin() as this = else window.minimize() tabs = this.ts.lorder.map <| fun tab -> - tab.GetHashCode().ToString(), { + let (Tab(hwnd)) = tab + TaskBarButton.key hwnd, { close = fun() -> let (Tab(hwnd)) = tab this.os.windowFromHwnd(hwnd).close() @@ -510,6 +531,7 @@ type SuperBarPlugin() as this = let button = match taskbarButton with | null -> + activePreview <- None let button = new TaskBarButton(tbButtonInfo) taskbarButton <- button button @@ -518,11 +540,13 @@ type SuperBarPlugin() as this = button - match this.foregroundTab |> Option.bind(fun tab -> button.tryTabWindow(tab.GetHashCode().ToString())) with - | Some(tabWindow) -> - this.taskbar.ActivateTab(button.window.hwnd) - this.taskbar.SetTabActive(tabWindow.hwnd, button.window.hwnd, 0) - | None -> () + // Only when it changes: the group's titles change far more often than its front tab. + let active = this.foregroundTab |> Option.bind(fun (Tab(hwnd)) -> button.tryTabWindow(TaskBarButton.key hwnd)) |> Option.map(fun w -> w.hwnd) + if active <> activePreview then + activePreview <- active + active |> Option.iter(fun preview -> + this.taskbar.ActivateTab(button.window.hwnd) + this.taskbar.SetTabActive(preview, button.window.hwnd, 0)) member this.onForegroundChanged() = this.updateTaskbar() @@ -532,6 +556,7 @@ type SuperBarPlugin() as this = member this.onRemoved(hwnd) = captures.forget hwnd + flashingTabs.Remove(TaskBarButton.key hwnd) |> ignore this.updateTaskbar() this.showInTaskbar(hwnd, true) @@ -547,9 +572,9 @@ type SuperBarPlugin() as this = /// A tab stops flashing when its window redraws, which it also does when printed for a /// preview. Invalidating then would make the taskbar print it again, over and over. - member this.onFlash(hwnd, flash) = + member this.onFlash(Tab(hwnd), flash) = if taskbarButton <> null then - let key = hwnd.GetHashCode().ToString() + let key = TaskBarButton.key hwnd taskbarButton.tryTabWindow(key) |> Option.iter(fun tabWindow -> if flash || flashingTabs.Contains key then if flash then flashingTabs.Add key |> ignore else flashingTabs.Remove key |> ignore diff --git a/tests/GroupLifecycle.fsx b/tests/GroupLifecycle.fsx index c9726f1..37a3b0a 100644 --- a/tests/GroupLifecycle.fsx +++ b/tests/GroupLifecycle.fsx @@ -153,6 +153,13 @@ let main() = pumpUntil "Preview order was not sent again" (fun () -> not button.isResendingTabOrder) button.update(info ["a";"b";"c"]) check (not button.isResendingTabOrder) "An unchanged order is sent again" + // A preview that leaves frees its window, class and procedure then, not with the group. + let leaving = (button.tryTabWindow "c").Value.hwnd + for _ in 1..20 do + button.update(info ["a";"b"]) + button.update(info ["a";"b";"c"]) + check (button.windowCount = 4) (sprintf "Previews that left kept %d windows" (button.windowCount-4)) + check (not(WinUserApi.IsWindow leaving) && not(TaskbarProxies.contains leaving)) "A preview that left kept its window" finally (button :> IDisposable).Dispose() // The setting rebuilds open groups that follow it, keeping what their tab menu chose.