Making the plain application keys focus an existing window was the wrong call. It reads well in a demo and it is what macOS does, but it made "give me another terminal" the awkward case -- and on a tiling desktop a second terminal beside the first is the normal way to work, not an edge case. Reaching for the launcher to open a second file manager is not an improvement on anything. So the plain keys do what they always did, and SUPER+ALT is the new capability rather than a tax on the old one: go to the terminal, editor, browser, files, calculator or mail you already have, wherever it is, and start one only if there is none. ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation space -- Files, Neovim and Settings would have collided with Focus session, Taller and Shorter, and breaking two keys out of the eight-key resize set to make room is the worse trade. Also fixes a real trap found while using it. The Alt-Tab overlay commits on SUPER release, which is a compositor bind running an IPC call; if that call ever fails to land, the overlay stayed up with no keyboard focus, no Escape handler and nothing clickable, so the only way out was an IPC call typed into a terminal it was covering. Clicking outside now dismisses it, clicking a row switches to that window -- which is the obvious thing to try and did nothing -- and an abandoned switch closes itself after ten seconds. Keyboard focus still stays with the compositor, because taking it mid-switch is what would break stepping.
163 lines
6.8 KiB
QML
163 lines
6.8 KiB
QML
// The Alt-Tab overlay.
|
|
//
|
|
// Deliberately a list of names rather than thumbnails: at a glance you are
|
|
// looking for "the other terminal", and a row of small live previews is both
|
|
// slower to read and considerably more expensive to draw than the gesture
|
|
// deserves. The dock already renders app identity this way, so the two agree.
|
|
//
|
|
// Only present while a switch is in progress -- there is nothing to keep alive
|
|
// between gestures, and a hidden always-loaded overlay is a surface that can
|
|
// go wrong while nobody is looking at it.
|
|
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
import qs.widgets
|
|
|
|
Loader {
|
|
id: root
|
|
|
|
// Plain `modelData`, not `required property var screen`. Variants supplies
|
|
// modelData, and shell.qml's own comment warns about exactly this: declaring
|
|
// `required property var screen` means the screen never resolves, the window
|
|
// is constructed and silently never maps, and NOTHING is logged. Bar and
|
|
// Dock both take the screen this way.
|
|
property var modelData: null
|
|
|
|
active: WindowSwitcherState.open
|
|
asynchronous: false
|
|
|
|
sourceComponent: PanelWindow {
|
|
screen: root.modelData
|
|
// Overlay so it sits above the focused window it is describing.
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-switcher"
|
|
// Keyboard focus stays with the compositor: the gesture is driven by
|
|
// binds, and taking focus mid-switch is the one thing that would break
|
|
// stepping. Pointer input is a different matter -- see below.
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
exclusionMode: ExclusionMode.Ignore
|
|
color: "transparent"
|
|
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
|
|
// Anywhere outside the card puts the switcher away.
|
|
//
|
|
// This is recovery, not decoration. The gesture commits on SUPER
|
|
// release, which is a compositor bind running `qs ipc call` -- and if
|
|
// that call ever fails to land, the overlay used to stay up with no
|
|
// keyboard focus, no Escape handler and nothing clickable, which meant
|
|
// the only way out was an IPC call from a terminal the overlay was
|
|
// covering.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: WindowSwitcherState.cancel()
|
|
}
|
|
|
|
// A switch nobody finished. Ten seconds is far longer than the gesture
|
|
// takes and far shorter than "forever", so a lost commit costs a pause
|
|
// rather than the session.
|
|
Timer {
|
|
running: WindowSwitcherState.open
|
|
interval: 10000
|
|
onTriggered: WindowSwitcherState.cancel()
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: Math.min(560, parent.width - 96)
|
|
implicitHeight: layout.implicitHeight + 24
|
|
radius: Theme.cardRadius
|
|
color: Theme.alpha(Theme.bgPopover, 0.97)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.fg, 0.09)
|
|
|
|
Column {
|
|
id: layout
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 2
|
|
|
|
Repeater {
|
|
model: WindowSwitcherState.windows
|
|
|
|
Rectangle {
|
|
id: row
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool current: row.index === WindowSwitcherState.index
|
|
readonly property string appId: row.modelData?.wayland?.appId ?? ""
|
|
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
|
|
|
|
width: parent.width
|
|
height: 44
|
|
radius: 10
|
|
border.width: 0
|
|
color: rowHover.hovered || row.current
|
|
? Theme.alpha(Theme.accent, row.current ? 0.20 : 0.10)
|
|
: "transparent"
|
|
|
|
// Pointing at a window and clicking it is the obvious
|
|
// thing to try, and it did nothing.
|
|
HoverHandler { id: rowHover }
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: WindowSwitcherState.selectAt(row.index)
|
|
}
|
|
|
|
Image {
|
|
id: icon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 10
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 24
|
|
height: 24
|
|
sourceSize.width: 24
|
|
sourceSize.height: 24
|
|
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
|
|
visible: source !== ""
|
|
}
|
|
|
|
Text {
|
|
anchors.left: icon.visible ? icon.right : parent.left
|
|
anchors.leftMargin: icon.visible ? 12 : 14
|
|
anchors.right: appName.left
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
// A window with no title yet is still a window you
|
|
// can switch to; naming it after its application is
|
|
// better than an empty row.
|
|
text: row.modelData?.title || row.entry?.name || row.appId
|
|
elide: Text.ElideRight
|
|
color: row.current ? Theme.fg : Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.weight: row.current ? Font.DemiBold : Font.Normal
|
|
}
|
|
|
|
Text {
|
|
id: appName
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
|
|
text: row.entry?.name ?? ""
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|