aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-15 11:03:49 +0200
committerDanilo M. <danix@danix.xyz>2026-10-15 11:03:49 +0200
commit00a3eb4047b76840233bb9ee872f20b7280ba427 (patch)
tree339acfe1e60890ac4d7582c29db4d590f71bb598
parent4cc85e59d7b3f53f4eec897f022bc4bcb643f185 (diff)
downloadquickshell-00a3eb4047b76840233bb9ee872f20b7280ba427.tar.gz
quickshell-00a3eb4047b76840233bb9ee872f20b7280ba427.zip
feat(window-switcher): the expose replaces the grid, with a hot corner
Previews are placed at their Expose.js rects and sized to them, so each keeps its window's shape and relative size. Title and close show on hover only, so the scatter stays clean; icon and workspace marker always show. The keyboard selection is gone: this is a pointer feature, and Escape is the one key left. The top-right corner of DP-1 opens it after a 250ms glow that grows out of the corner, which is the warning and the way out. Input is only the corner's 2x2 pixels, since a larger input region would eat clicks on the windows below. It re-arms only when the pointer leaves while the expose is closed, or an Escape with the pointer still in the corner reopens it. It hides while status.gaming is set, read straight from the file: linking shared/Status.qml would start a second game poll and fire presentation side effects twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--window-switcher/HotCorner.qml101
-rw-r--r--window-switcher/Switcher.qml95
-rw-r--r--window-switcher/WindowCard.qml135
-rw-r--r--window-switcher/shell.qml5
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 {