aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-18 12:26:10 +0200
committerDanilo M. <danix@danix.xyz>2026-10-18 12:26:10 +0200
commit1f6bd402b5d2ffef845129a327cae95113b23e55 (patch)
treeb522d59b159b82de33eb8014dda815c2f5b62fa7 /window-switcher
parenta880134140f7ab8195c6651c2f64f614855c8dce (diff)
downloadquickshell-1f6bd402b5d2ffef845129a327cae95113b23e55.tar.gz
quickshell-1f6bd402b5d2ffef845129a327cae95113b23e55.zip
window-switcher: animate, respace and label the expose, add arrow keys
Closing a window from the expose now glides the rest into their new rects instead of jumping. The Repeater's plain array model rebuilt every card on any change, leaving nothing to animate; ScriptModel diffs by identity, so only the closed card goes. The Behaviors stay off until the fade-in settles, or every card would fly in from the corner on open. The scatter keeps its centre weighting but spaces windows further apart (GAP 60 -> 200 real px) and may fill 95% of the screen rather than 92%. Measured with node at 7 windows: about 45px on screen between cards. The bottom 28px are reserved so the lowest card's name stays on screen. Each card shows the app's desktop-entry name below its preview, falling back to the app id, and the app icon moved to a larger overlay in the preview's bottom-left corner; the hover title starts right of it. Arrow keys select a card and Enter focuses it. The scatter has no grid, so Expose.step picks the nearest centre on the arrow's side with sideways drift counted double, and the first press picks the top-left card; test_expose.js covers it. The selection is held as the window, not an index, so a close that shifts the list does not move it, and hovering selects too, so pointer and keys never show two highlights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'window-switcher')
-rw-r--r--window-switcher/Expose.js31
-rw-r--r--window-switcher/README.md10
-rw-r--r--window-switcher/Switcher.qml42
-rw-r--r--window-switcher/WindowCard.qml56
-rw-r--r--window-switcher/test_expose.js13
5 files changed, 126 insertions, 26 deletions
diff --git a/window-switcher/Expose.js b/window-switcher/Expose.js
index 4120907..344fdde 100644
--- a/window-switcher/Expose.js
+++ b/window-switcher/Expose.js
@@ -24,9 +24,9 @@
// pushing a pile apart makes a line that still overlapped after 300 passes
// and filled under 15% of the screen.
-const GAP = 60; // real pixels kept between two windows
+const GAP = 200; // real pixels kept between two windows, room for the name
const PASSES = 300;
-const FILL = 0.92; // of the screen, each way
+const FILL = 0.95; // of the screen, each way
const MAX_SCALE = 0.5; // a lone window is not a full-screen mirror
const SPREAD = 0.45; // spiral step, as a fraction of the tallest window
const ROUNDS = 30;
@@ -94,3 +94,30 @@ function layout(rects, W, H) {
return r.map(o => ({ x: dx + (o.x - x0) * k, y: dy + (o.y - y0) * k,
w: o.w * k, h: o.h * k }));
}
+
+// Keyboard selection over the scatter, which has no rows or columns to step
+// through. Returns the index of the card the arrow points at from card i:
+// the nearest centre on that side, with sideways drift counted double so
+// Right prefers the card level with this one over a closer one far above.
+// With no card on that side the selection stays put. With nothing selected
+// yet (i < 0) any arrow starts from the top-left card.
+function step(rects, i, dx, dy) {
+ const c = o => ({ x: o.x + o.w / 2, y: o.y + o.h / 2 });
+ if (i < 0) {
+ let best = -1;
+ for (let k = 0; k < rects.length; k++)
+ if (best < 0 || rects[k].x + rects[k].y < rects[best].x + rects[best].y) best = k;
+ return best;
+ }
+ const from = c(rects[i]);
+ let best = i, score = Infinity;
+ for (let k = 0; k < rects.length; k++) {
+ if (k === i) continue;
+ const to = c(rects[k]);
+ const ahead = (to.x - from.x) * dx + (to.y - from.y) * dy;
+ if (ahead <= 0) continue;
+ const side = Math.abs((to.x - from.x) * dy) + Math.abs((to.y - from.y) * dx);
+ if (ahead + 2 * side < score) { score = ahead + 2 * side; best = k; }
+ }
+ return best;
+}
diff --git a/window-switcher/README.md b/window-switcher/README.md
index a0e72f4..fb3dc8d 100644
--- a/window-switcher/README.md
+++ b/window-switcher/README.md
@@ -57,11 +57,15 @@ shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and
| top-right corner of DP-1 | open, after the glow (see below) |
| click a preview | focus that window and dismiss |
| the `x` on a hovered preview | close that window, overlay stays open |
+| arrow keys | select a preview; the first press picks the top-left one |
+| Enter | focus the selected window and dismiss |
| Escape, or a click on the backdrop | dismiss, focus unchanged |
-The title and the `x` show only on hover, which also draws the accent border.
-There is no keyboard selection: this is a pointer feature, and the keyboard
-keeps only the way in and Escape.
+The selected preview draws the glow, the accent border and its title. Hovering
+a preview selects it, so the pointer and the arrows share one selection rather
+than showing two. The scatter has no rows or columns, so an arrow moves to the
+nearest preview on that side, preferring one level with the current one
+(`Expose.step`). The `x` stays hover-only: closing is a pointer errand.
Closing leaves the overlay up on purpose. Tidying several windows would
otherwise mean reopening between each one, and the preview goes when Hyprland
diff --git a/window-switcher/Switcher.qml b/window-switcher/Switcher.qml
index e6eb792..1eebfa8 100644
--- a/window-switcher/Switcher.qml
+++ b/window-switcher/Switcher.qml
@@ -81,11 +81,16 @@ 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 {
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.close()
+ Keys.onReturnPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
+ Keys.onEnterPressed: if (scatter.selected) root.focusAfterClose(scatter.selected)
+ Keys.onLeftPressed: scatter.move(-1, 0)
+ Keys.onRightPressed: scatter.move(1, 0)
+ Keys.onUpPressed: scatter.move(0, -1)
+ Keys.onDownPressed: scatter.move(0, 1)
Rectangle {
anchors.fill: parent
@@ -127,12 +132,14 @@ Scope {
// Real sizes come from the raw hyprctl object. A window
// that has none yet stands in at 1280x800 until it does.
+ // The bottom 28px are kept free for the lowest card's
+ // app name, which hangs below its preview.
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)
+ }), width, height - 28)
// ponytail: a fade, not a flight from each window's real
// position; add the flight if the fade feels abrupt.
@@ -140,8 +147,30 @@ Scope {
Component.onCompleted: opacity = 1
Behavior on opacity { NumberAnimation { duration: 150 } }
+ // The keyboard selection, held as the window rather
+ // than an index, so a close that shifts the list
+ // does not move it onto a neighbour. Hovering a card
+ // selects it too, so pointer and keys never show two.
+ property var selected: null
+ readonly property int selectedIndex: Windows.windows.indexOf(selected)
+
+ function move(dx, dy) {
+ const i = Expose.step(rects, selectedIndex, dx, dy);
+ if (i >= 0) selected = Windows.windows[i];
+ }
+
+ // Cards glide to their new rects when the scatter
+ // changes, but not while it fades in, or every card
+ // would fly in from the corner on open.
+ property bool settled: false
+ onOpacityChanged: if (opacity === 1) settled = true
+
+ // A plain array model would rebuild every card on any
+ // change, leaving nothing to animate. ScriptModel diffs
+ // by identity, so a closed window removes one delegate
+ // and the rest keep theirs.
Repeater {
- model: Windows.windows
+ model: ScriptModel { values: Windows.windows }
WindowCard {
required property int index
@@ -156,6 +185,13 @@ Scope {
width: rect.w
height: rect.h
+ Behavior on x { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+ Behavior on y { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+ Behavior on width { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+ Behavior on height { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+
+ selected: scatter.selected === modelData
+ onHoveredChanged: if (hovered) scatter.selected = modelData
onActivated: root.focusAfterClose(modelData)
// Closing is a side errand, so the overlay
// stays open: a dismissal would mean reopening
diff --git a/window-switcher/WindowCard.qml b/window-switcher/WindowCard.qml
index be97427..b7b69f1 100644
--- a/window-switcher/WindowCard.qml
+++ b/window-switcher/WindowCard.qml
@@ -15,12 +15,15 @@ import Quickshell.Widgets
import QtQuick
// 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.
+// the scatter. App icon, workspace marker and app name are always shown;
+// title only on the selected card, close only on hover, so the scatter
+// stays clean.
Item {
id: card
required property var toplevel
+ property bool selected: false
+ readonly property bool hovered: cardHover.hovered
signal activated()
signal closeRequested()
@@ -43,7 +46,7 @@ Item {
Glow {
anchors.fill: box
radius: box.radius
- on: cardHover.hovered
+ on: card.selected
}
Rectangle {
@@ -51,7 +54,7 @@ Item {
anchors.fill: parent
radius: 10
color: Theme.surface
- border.color: cardHover.hovered ? Theme.accent : Theme.surfaceAlt
+ border.color: card.selected ? Theme.accent : Theme.surfaceAlt
border.width: 1
// The rect already has the window's shape, so the preview fills it.
@@ -84,17 +87,6 @@ Item {
anchors { left: parent.left; top: parent.top; margins: 8 }
spacing: 6
- 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.
@@ -147,8 +139,21 @@ Item {
}
Rectangle {
- visible: cardHover.hovered
- anchors { left: parent.left; right: parent.right; bottom: parent.bottom; margins: 8 }
+ id: appIcon
+ anchors { left: parent.left; bottom: parent.bottom; margins: 8 }
+ width: 40; height: 40; radius: 10
+ color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75)
+
+ IconImage {
+ anchors.centerIn: parent
+ width: 30; height: 30
+ source: Quickshell.iconPath(card.wl ? card.wl.appId : "", true)
+ }
+ }
+
+ Rectangle {
+ visible: card.selected
+ anchors { left: appIcon.right; 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)
@@ -163,4 +168,21 @@ Item {
}
}
}
+
+ // The app's name hangs below the preview, outside the card's rect: the
+ // scatter's gap leaves room for it. The desktop entry's name reads
+ // better than an app id, which is the fallback.
+ Text {
+ anchors { top: box.bottom; topMargin: 6; horizontalCenter: box.horizontalCenter }
+ width: Math.min(implicitWidth, box.width)
+ elide: Text.ElideRight
+ text: {
+ const id = card.wl ? card.wl.appId : "";
+ const e = id ? DesktopEntries.heuristicLookup(id) : null;
+ return e ? e.name : id;
+ }
+ color: card.selected ? Theme.text : Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 14
+ }
}
diff --git a/window-switcher/test_expose.js b/window-switcher/test_expose.js
index d394253..e8ef459 100644
--- a/window-switcher/test_expose.js
+++ b/window-switcher/test_expose.js
@@ -18,7 +18,7 @@ const assert = require("assert");
const src = fs.readFileSync(__dirname + "/Expose.js", "utf8")
.replace(/^\.pragma library$/m, "");
-const { layout } = new Function(src + "\nreturn { layout };")();
+const { layout, step } = new Function(src + "\nreturn { layout, step };")();
const W = 2560, H = 1080;
const wide = { w: 2556, h: 1076 }, tall = { w: 1076, h: 1916 };
@@ -56,4 +56,15 @@ const [a, b] = layout([wide, tall], W, H);
assert.ok(Math.abs(a.w / b.w - wide.w / tall.w) < 1e-9);
assert.ok(Math.abs(a.h / b.h - wide.h / tall.h) < 1e-9);
+// Arrow selection: a 2x2 grid, plus one far off to the upper right.
+const g = [{ x: 0, y: 0, w: 10, h: 10 }, { x: 20, y: 0, w: 10, h: 10 },
+ { x: 0, y: 20, w: 10, h: 10 }, { x: 20, y: 20, w: 10, h: 10 },
+ { x: 100, y: -60, w: 10, h: 10 }];
+assert.strictEqual(step(g, -1, 1, 0), 0); // top-left first
+assert.strictEqual(step(g, 0, 1, 0), 1); // level neighbour beats the far one
+assert.strictEqual(step(g, 0, 0, 1), 2);
+assert.strictEqual(step(g, 3, -1, 0), 2);
+assert.strictEqual(step(g, 2, -1, 0), 2); // nothing to the left: stays
+assert.strictEqual(step([], -1, 1, 0), -1);
+
console.log("test_expose: ok");