diff options
Diffstat (limited to 'window-switcher')
| -rw-r--r-- | window-switcher/HotCorner.qml | 101 | ||||
| -rw-r--r-- | window-switcher/Switcher.qml | 95 | ||||
| -rw-r--r-- | window-switcher/WindowCard.qml | 135 | ||||
| -rw-r--r-- | window-switcher/shell.qml | 5 |
4 files changed, 202 insertions, 134 deletions
diff --git a/window-switcher/HotCorner.qml b/window-switcher/HotCorner.qml new file mode 100644 index 0000000..cb7d863 --- /dev/null +++ b/window-switcher/HotCorner.qml @@ -0,0 +1,101 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +// The top-right corner of DP-1 opens the expose. Touching it grows a glow out +// of the corner over 250ms and the expose opens when the glow is full; +// leaving first shrinks it back and nothing happens. The dwell is what keeps +// a pointer passing through from triggering it, and the glow is the warning. +PanelWindow { + id: corner + + // True while the expose is up. Set by shell.qml. + property bool blocked: false + + signal triggered() + + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { top: true; right: true } + implicitWidth: 48 + implicitHeight: 48 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-hot-corner" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // Off while a game runs. Read straight from the status registry's file: + // linking shared/Status.qml instead would start a second copy of its game + // poll and its presentation side effects in this process. + visible: !gaming + property bool gaming: false + + FileView { + id: gamingFile + path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/status.gaming" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: corner.gaming = text().trim() === "1" + // A missing file is the off state. + onLoadFailed: corner.gaming = false + } + + // Input only on the corner's 2x2 pixels. The rest of the 48x48 is the + // glow, drawn but click-through: a larger input area would swallow clicks + // meant for the windows underneath, which is also why the glow cannot + // start before the pointer is in the corner. + mask: Region { item: patch } + + Item { + id: patch + anchors { top: parent.top; right: parent.right } + width: 2 + height: 2 + + HoverHandler { + id: hover + // Re-armed only by leaving while the expose is closed. Opening it + // covers the corner, which reads as leaving; closing it with + // Escape while the pointer still sits here would otherwise reopen + // it 250ms later. + onHoveredChanged: if (!hovered && !corner.blocked) corner.armed = true + } + } + + property bool armed: true + + property real progress: hover.hovered && armed && !blocked ? 1 : 0 + Behavior on progress { NumberAnimation { duration: 250 } } + + onProgressChanged: { + if (progress >= 1) { + armed = false; + triggered(); + } + } + + // A quarter circle centred on the corner, growing with the dwell. + Rectangle { + readonly property real r: corner.width * corner.progress + x: corner.width - r + y: -r + width: 2 * r + height: 2 * r + radius: r + color: Theme.accent + opacity: 0.6 * corner.progress + } +} diff --git a/window-switcher/Switcher.qml b/window-switcher/Switcher.qml index d907ab7..bd5c607 100644 --- a/window-switcher/Switcher.qml +++ b/window-switcher/Switcher.qml @@ -12,6 +12,7 @@ import Quickshell import Quickshell.Wayland import QtQuick +import "Expose.js" as Expose Scope { id: root @@ -80,39 +81,11 @@ Scope { // Keys reach a focused item, not a window: Keys.onEscapePressed on // the PanelWindow above never fires. This item is what listens. + // Escape is the only key: the expose is a pointer feature. Item { - id: keyHandler anchors.fill: parent focus: true - - // The LazyLoader destroys the overlay when it closes and - // builds a new one on the next opening, verified by a build - // counter: two openings, two constructions. So this starts at - // 0 every time on its own, and there is nothing to reset. A - // Component.onCompleted assigning 0 here would be a no-op. - property int selectedIndex: 0 - - function commit() { - const list = Windows.windows; - if (selectedIndex >= 0 && selectedIndex < list.length) - root.focusAfterClose(list[selectedIndex]); - } - - function move(delta) { - const n = Windows.windows.length; - if (n === 0) return; - selectedIndex = (selectedIndex + delta + n) % n; - } - Keys.onEscapePressed: root.close() - Keys.onReturnPressed: commit() - Keys.onEnterPressed: commit() - Keys.onLeftPressed: move(-1) - Keys.onRightPressed: move(1) - Keys.onUpPressed: move(-grid.columns) - Keys.onDownPressed: move(grid.columns) - Keys.onTabPressed: move(1) - Keys.onBacktabPressed: move(-1) Rectangle { anchors.fill: parent @@ -146,38 +119,26 @@ Scope { } } - Grid { - id: grid - anchors.centerIn: parent - visible: Windows.windows.length > 0 - - readonly property int count: Windows.windows.length - readonly property int gap: 30 - readonly property int maxCardWidth: 640 - - // Columns are the window count capped at six, so one - // card sits dead centre and two straddle the middle. - columns: Math.max(1, Math.min(count, 6)) - - // Cards grow to fill their row, up to a ceiling. - // Without the ceiling a lone preview becomes a - // full-screen mirror of the window it stands for. - readonly property int rows: Math.ceil(count / columns) - - // The three text lines and the spacing above them, - // which the box height has to leave room for. - readonly property int textHeight: 54 - - // Clamped by the height as well as the width. Without - // the second term a busy desktop pushes rows off the - // top and bottom, and a centred Grid has no way to - // scroll to them, so the cards shrink instead. - readonly property int cardWidth: Math.min( - maxCardWidth, - Math.floor((parent.width * 0.92 - (columns - 1) * gap) / columns), - Math.floor(((parent.height * 0.92 - (rows - 1) * gap) / rows - textHeight) * 16 / 10)) + // The scatter. Recomputed whenever the window list or a + // window's size changes, so closing one moves the rest. + Item { + id: scatter + anchors.fill: parent - spacing: gap + // Real sizes come from the raw hyprctl object. A window + // that has none yet stands in at 1280x800 until it does. + readonly property var rects: Expose.layout( + Windows.windows.map(t => { + const o = t.lastIpcObject; + const s = o && o.size; + return s ? { w: s[0], h: s[1] } : { w: 1280, h: 800 }; + }), width, height) + + // ponytail: a fade, not a flight from each window's real + // position; add the flight if the fade feels abrupt. + opacity: 0 + Component.onCompleted: opacity = 1 + Behavior on opacity { NumberAnimation { duration: 150 } } Repeater { model: Windows.windows @@ -186,14 +147,16 @@ Scope { required property int index required property var modelData + readonly property var rect: scatter.rects[index] + ?? { x: 0, y: 0, w: 0, h: 0 } + toplevel: modelData - cardWidth: grid.cardWidth - selected: index === keyHandler.selectedIndex + x: rect.x + y: rect.y + width: rect.w + height: rect.h - onActivated: { - keyHandler.selectedIndex = index; - keyHandler.commit(); - } + onActivated: root.focusAfterClose(modelData) // Closing is a side errand, so the overlay // stays open: a dismissal would mean reopening // to close a second window. The card goes when diff --git a/window-switcher/WindowCard.qml b/window-switcher/WindowCard.qml index b671f0f..177aac9 100644 --- a/window-switcher/WindowCard.qml +++ b/window-switcher/WindowCard.qml @@ -14,12 +14,13 @@ import Quickshell.Wayland import Quickshell.Widgets import QtQuick -Column { +// One window in the expose: a live preview sized from outside to its rect in +// the scatter. App icon and workspace marker are always shown; title and +// close only on hover, so the scatter stays clean. +Item { id: card required property var toplevel - property bool selected: false - property int cardWidth: 380 signal activated() signal closeRequested() @@ -37,34 +38,29 @@ Column { return name !== "" ? Quickshell.iconPath(name, true) : ""; } - width: cardWidth - spacing: 8 + HoverHandler { id: cardHover } - // A fixed 16:10 box with the preview fitted inside it. The previews are - // not one shape: DP-1 windows are near 21:9 and DP-3 is rotated, so its - // windows are portrait. Sizing each card to its own preview would give - // ragged rows and break the alignment of the text lines below. Rectangle { id: box - width: card.cardWidth - height: Math.round(card.cardWidth * 10 / 16) + anchors.fill: parent radius: 10 color: Theme.surface - border.color: card.selected ? Theme.accent : Theme.surfaceAlt - border.width: card.selected ? 3 : 1 + border.color: cardHover.hovered ? Theme.accent : Theme.surfaceAlt + border.width: cardHover.hovered ? 3 : 1 + // The rect already has the window's shape, so the preview fills it. + // The fit is kept for the moment before a capture lands: an + // uncaptured view reports sourceSize (-1, -1) rather than (0, 0), so + // this tests for a positive height, falling back to the box's ratio. ScreencopyView { id: preview anchors.centerIn: parent captureSource: card.wl live: true - // An uncaptured view reports sourceSize (-1, -1) rather than - // (0, 0), so this tests for a positive height rather than a - // non-zero one, and falls back to the box's own ratio. readonly property real ar: sourceSize.height > 0 ? sourceSize.width / sourceSize.height - : 16 / 10 + : parent.width / Math.max(1, parent.height) width: Math.min(parent.width - 8, (parent.height - 8) * ar) height: Math.min(parent.height - 8, (parent.width - 8) / ar) } @@ -74,24 +70,53 @@ Column { onClicked: card.activated() } - // Both corner overlays sit on an arbitrary window preview, so each - // carries its own scrim: a themed icon can otherwise land on a - // same-coloured region and vanish, a light icon on a white page being - // the obvious case. - Rectangle { + // Every overlay sits on an arbitrary window preview, so each carries + // its own scrim: a themed icon can otherwise land on a same-coloured + // region and vanish, a light icon on a white page being the obvious + // case. + Row { anchors { left: parent.left; top: parent.top; margins: 8 } - width: 34; height: 34; radius: 8 - color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) + spacing: 6 - IconImage { - anchors.centerIn: parent - width: 24; height: 24 - source: Quickshell.iconPath(card.wl ? card.wl.appId : "", true) + Rectangle { + width: 34; height: 34; radius: 8 + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) + + IconImage { + anchors.centerIn: parent + width: 24; height: 24 + source: Quickshell.iconPath(card.wl ? card.wl.appId : "", true) + } + } + + // The desktop marker, drawn with the workspace icon rather than + // the number. A workspace with no icon keeps the name, so the + // marker never goes blank. + Rectangle { + width: Math.max(34, wsName.implicitWidth + 12); height: 34; radius: 8 + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) + + IconImage { + anchors.centerIn: parent + width: 22; height: 22 + source: card.workspaceIconPath + } + + Text { + id: wsName + anchors.centerIn: parent + visible: card.workspaceIconPath === "" + text: card.toplevel.workspace ? card.toplevel.workspace.name : "?" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 13 + } } } Rectangle { id: closeButton + visible: cardHover.hovered anchors { right: parent.right; top: parent.top; margins: 8 } width: 34; height: 34; radius: 17 color: closeArea.pressed @@ -114,48 +139,22 @@ Column { onClicked: card.closeRequested() } } - } - - Text { - width: parent.width - horizontalAlignment: Text.AlignHCenter - elide: Text.ElideRight - text: card.wl ? card.wl.appId : "" - color: Theme.text - font.family: Theme.fontFamily - font.pixelSize: 16 - font.bold: true - } - Text { - width: parent.width - elide: Text.ElideRight - text: card.toplevel.title - color: Theme.subtext - font.family: Theme.fontFamily - font.pixelSize: 13 - } - - // The desktop marker, drawn with waybar's workspace icon rather than the - // number. A workspace with no icon keeps the number, so the line never - // goes blank. - Item { - width: card.width - height: 24 - - IconImage { - anchors.centerIn: parent - width: 22; height: 22 - source: card.workspaceIconPath - } + Rectangle { + visible: cardHover.hovered + anchors { left: parent.left; right: parent.right; bottom: parent.bottom; margins: 8 } + height: 30; radius: 8 + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) - Text { - anchors.centerIn: parent - visible: card.workspaceIconPath === "" - text: "[" + (card.toplevel.workspace ? card.toplevel.workspace.name : "?") + "]" - color: Theme.accent - font.family: Theme.fontFamily - font.pixelSize: 13 + Text { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: card.toplevel.title + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 13 + } } } } diff --git a/window-switcher/shell.qml b/window-switcher/shell.qml index 1581f62..0ebe553 100644 --- a/window-switcher/shell.qml +++ b/window-switcher/shell.qml @@ -15,6 +15,11 @@ import Quickshell.Io ShellRoot { Switcher { id: switcher } + HotCorner { + blocked: switcher.open + onTriggered: switcher.open = true + } + // Bound to ALT + TAB in Hyprland: // `qs -p <this dir> ipc call switcher toggle` IpcHandler { |
