aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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 {