aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/Island.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-07 18:58:11 +0200
committerDanilo M. <danix@danix.xyz>2026-10-07 18:58:11 +0200
commitf54639e3bee6803fc884135783c0633318a161a2 (patch)
tree9ee4a0f5cac51ceaf71bf4f232ec300336d0eadb /desktop/island/Island.qml
parentb324ff0fb5cbc13a038ce2e5a2a4af69598bb91a (diff)
downloadquickshell-f54639e3bee6803fc884135783c0633318a161a2.tar.gz
quickshell-f54639e3bee6803fc884135783c0633318a161a2.zip
feat(island): the island window on DP-1
A fixed overlay strip that never resizes: the pills animate inside it and the input mask follows their rectangles, so resizing a layer surface during a morph never happens. Hidden behind a sliver that also carries the privacy colours; the clock pill drops alone for a workspace or app-open flick. shell.qml gains the UseQApplication pragma, which tray menus need, and the island holds itself shown while a tray menu is open, since the menu is a separate popup that would otherwise un-hover the pills and let the hide timer fire. The hold ends at the next hover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/island/Island.qml')
-rw-r--r--desktop/island/Island.qml205
1 files changed, 205 insertions, 0 deletions
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml
new file mode 100644
index 0000000..b471e3c
--- /dev/null
+++ b/desktop/island/Island.qml
@@ -0,0 +1,205 @@
+// 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.Wayland
+import Quickshell.Hyprland
+import QtQuick
+import ".."
+import "Logic.js" as Logic
+
+// The island: a sliver at the top centre of DP-1 that the two pills slide
+// out of. The window is a fixed overlay strip that never resizes; the pills
+// move inside it and the input mask follows them, so the rest of the strip
+// is click-through.
+PanelWindow {
+ id: win
+
+ required property QtObject sound // the sound module's Service
+ required property QtObject drawer // Drawer, for the sound page link
+
+ screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0]
+ anchors { top: true; left: true; right: true }
+ implicitHeight: 360
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-island"
+ WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
+
+ // "" or "sound".
+ property string panel: ""
+
+ // --- reveal and hide -------------------------------------------------
+
+ readonly property bool hovered: sliverHit.containsMouse || clock.hovered || status.hovered
+ property bool shown: false
+
+ // A tray menu is a separate popup: moving onto it un-hovers the pills,
+ // and the hide timer would then hide the island under the open menu. The
+ // menu cannot report its own close through display(), so the hold ends
+ // the next time the pointer comes back to the island.
+ // ponytail: if the menu is dismissed elsewhere, the island may stay shown
+ // until the next hover.
+ property bool menuHeld: false
+
+ onHoveredChanged: {
+ if (hovered) { hideTimer.stop(); shown = true; menuHeld = false; }
+ else if (panel === "" && !menuHeld) hideTimer.restart();
+ }
+ onPanelChanged: {
+ if (panel !== "") keys.forceActiveFocus();
+ else if (!hovered) hideTimer.restart();
+ }
+
+ Timer {
+ id: hideTimer
+ interval: 400
+ onTriggered: if (!win.hovered && win.panel === "" && !win.menuHeld) win.shown = false
+ }
+
+ // 0 hidden, 1 revealed. One amount drives the split, so an interrupted
+ // reveal turns around from wherever it is.
+ property real reveal: shown ? 1 : 0
+ Behavior on reveal { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } }
+
+ // The clock pill also drops alone for a flick while the island is hidden.
+ property real clockDrop: (shown || flicking) ? 1 : 0
+ Behavior on clockDrop { NumberAnimation { duration: 280; easing.type: Easing.OutCubic } }
+
+ // --- flick -----------------------------------------------------------
+
+ // Hyprland reports the initial state as events at startup.
+ property bool armed: false
+ Timer { interval: 1500; running: true; onTriggered: win.armed = true }
+
+ property bool flicking: false
+ property string flickWs: ""
+ property string flickApp: ""
+
+ function flick(ws, app) {
+ flickWs = ws;
+ flickApp = app;
+ flicking = true;
+ flickTimer.restart();
+ }
+
+ Timer { id: flickTimer; interval: 1500; onTriggered: win.flicking = false }
+
+ // activeToplevel settles a moment after workspacev2 arrives.
+ Timer {
+ id: settle
+ interval: 60
+ onTriggered: {
+ const ws = Hyprland.focusedWorkspace;
+ const top = Hyprland.activeToplevel;
+ const app = top && top.workspace === ws ? (top.wayland?.appId ?? "") : "";
+ win.flick(ws?.name ?? "", app);
+ }
+ }
+
+ Connections {
+ target: Hyprland
+ function onRawEvent(event) {
+ if (!win.armed) return;
+ if (event.name === "workspacev2") {
+ settle.restart();
+ } else if (event.name === "openwindow") {
+ const w = Logic.parseOpenWindow(event.data);
+ if (!w.workspace.startsWith("special:")) win.flick(w.workspace, w.appClass);
+ }
+ }
+ }
+
+ // --- dismissal -------------------------------------------------------
+
+ HyprlandFocusGrab {
+ windows: [win]
+ active: win.panel !== ""
+ onCleared: win.panel = ""
+ }
+
+ // Key events reach a focused item, not the window (AGENTS.md).
+ Item {
+ id: keys
+ Keys.onEscapePressed: win.panel = ""
+ }
+
+ // --- geometry --------------------------------------------------------
+
+ readonly property real gap: 10
+ readonly property real top: 8
+ readonly property real cx: width / 2
+
+ mask: Region {
+ item: sliverHit
+ Region { item: clock; intersection: Intersection.Combine }
+ Region { item: status; intersection: Intersection.Combine }
+ }
+
+ // The visible sliver: accent at rest, the capture colours while
+ // something captures, one equal segment per active kind.
+ Row {
+ id: sliver
+ x: win.cx - width / 2
+ y: 0
+ width: 120
+ height: 4
+
+ Repeater {
+ model: Privacy.roles.length ? Privacy.roles : ["accent"]
+ delegate: Rectangle {
+ required property string modelData
+ width: sliver.width / Math.max(1, Privacy.roles.length)
+ height: sliver.height
+ radius: 2
+ color: Theme[modelData]
+ }
+ }
+ }
+
+ MouseArea {
+ id: sliverHit
+ x: win.cx - width / 2
+ y: 0
+ width: 220
+ height: 10
+ hoverEnabled: true
+ }
+
+ // Both pills start overlapped at the sliver, above the screen edge, and
+ // travel down and apart. At reveal 1 the clock ends gap/2 left of the
+ // centre and the status gap/2 right of it; the status pill's left edge
+ // stays put while its panel widens it.
+ ClockPill {
+ id: clock
+ x: win.cx - width / 2 - (win.gap / 2 + width / 2) * win.reveal
+ y: -height + (win.top + height) * win.clockDrop
+ opacity: Math.min(1, win.clockDrop * 2)
+ flicking: win.flicking
+ flickWs: win.flickWs
+ flickApp: win.flickApp
+ }
+
+ StatusPill {
+ id: status
+ x: win.cx - width / 2 + (win.gap / 2 + width / 2) * win.reveal
+ y: -height + (win.top + height) * win.reveal
+ opacity: Math.min(1, win.reveal * 2)
+ sound: win.sound
+ window: win
+ panel: win.panel
+ onMenuShown: win.menuHeld = true
+ onOpenSound: win.panel = win.panel === "sound" ? "" : "sound"
+ onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); }
+ }
+}