diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-07 18:58:11 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-07 18:58:11 +0200 |
| commit | f54639e3bee6803fc884135783c0633318a161a2 (patch) | |
| tree | 9ee4a0f5cac51ceaf71bf4f232ec300336d0eadb /desktop/island/Island.qml | |
| parent | b324ff0fb5cbc13a038ce2e5a2a4af69598bb91a (diff) | |
| download | quickshell-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.qml | 205 |
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"); } + } +} |
