aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher
diff options
context:
space:
mode:
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");