diff options
| -rw-r--r-- | window-switcher/Expose.js | 31 | ||||
| -rw-r--r-- | window-switcher/README.md | 10 | ||||
| -rw-r--r-- | window-switcher/Switcher.qml | 42 | ||||
| -rw-r--r-- | window-switcher/WindowCard.qml | 56 | ||||
| -rw-r--r-- | window-switcher/test_expose.js | 13 |
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"); |
