pragma Singleton // Alt-Tab, with something on screen while you do it. // // Named WindowSwitcherState rather than WindowSwitcher: the overlay component // in modules/switcher already owns that name, and a singleton sharing it is // silently shadowed wherever both are imported -- the same failure that made an // earlier Locale singleton resolve to QML's built-in type instead. // // Super+Tab already cycled windows; nothing was drawn, so you were choosing // blind and could only confirm by arriving. This holds the selection while a // switch is in progress and lets the overlay render it. // // MOST-RECENTLY-USED ORDER // // The list is ordered by when each window last had focus, not by when it was // opened, because that is what makes the gesture useful: one Tab returns to the // window you just came from, which is the overwhelmingly common case. Creation // order would send you to whichever window happens to be first in Hyprland's // list, which is arbitrary from the user's point of view. // // Hyprland does not report an MRU order, so it is tracked here: every time a // toplevel becomes active it moves to the front. Addresses are used as the key // because they are stable for a window's lifetime, where titles and app ids are // not. // // HOW A SWITCH ENDS // // The compositor fires a bind on Super RELEASE, which commits. That is the only // way to know the gesture is over -- there is no "modifier released" signal // otherwise. It means close() runs on every Super release in the session, so it // must be cheap and a no-op when nothing is open. import Quickshell import Quickshell.Hyprland import QtQuick Singleton { id: root property bool open: false property int index: 0 // Window addresses, most recently focused first. property var recent: [] // The switch candidates, resolved fresh each time the gesture starts. property var windows: [] readonly property var selected: (root.index >= 0 && root.index < root.windows.length) ? root.windows[root.index] : null // Ordered by the MRU list, with anything unseen appended in Hyprland's own // order so a brand new window is still reachable. function orderedWindows(): var { const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId); const byAddress = {}; for (const toplevel of all) byAddress[String(toplevel.address)] = toplevel; const ordered = []; for (const address of root.recent) { const match = byAddress[address]; if (match) { ordered.push(match); delete byAddress[address]; } } for (const toplevel of all) if (byAddress[String(toplevel.address)]) ordered.push(toplevel); return ordered; } // Starts the gesture if it is not already running, then steps. The first // Tab lands on the PREVIOUS window rather than the current one, which is // what every other implementation of this gesture does. function step(forward: bool): void { if (!root.open) { root.windows = root.orderedWindows(); if (root.windows.length < 2) return; root.open = true; root.index = forward ? 1 : root.windows.length - 1; return; } if (root.windows.length === 0) return; const count = root.windows.length; root.index = forward ? (root.index + 1) % count : (root.index - 1 + count) % count; } // Runs on every Super release in the session, so it does as little as // possible when no switch is in progress. function commit(): void { if (!root.open) return; const target = root.selected; root.open = false; root.windows = []; root.index = 0; if (target && target.wayland) target.wayland.activate(); } function cancel(): void { root.open = false; root.windows = []; root.index = 0; } // Focus changes maintain the MRU order. This runs whether or not a switch // is in progress, because ordinary clicking between windows is most of how // the order is established. Connections { target: Hyprland function onActiveToplevelChanged(): void { const active = Hyprland.activeToplevel; if (!active || !active.address) return; const address = String(active.address); const next = [address]; for (const existing of root.recent) if (existing !== address) next.push(existing); // Bounded: a session can accumulate a lot of closed addresses, and // this list is only ever used to order what is currently open. root.recent = next.slice(0, 64); } } }