// Copyright (C) 2026 Danilo M. // // 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 Quickshell.Services.Mpris import QtQuick import ".." import "../modules/sound" import "Logic.js" as Logic import "../assistant/Files.js" as Files // 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 property QtObject asst: null // the assistant canvas, for dropped files screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] anchors { top: true; left: true; right: true } // Tall enough for the largest panel; the mask keeps everything outside // the pills click-through. implicitHeight: 480 color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-island" WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None // "", "sound" or "calendar". property string panel: "" // --- reveal and hide ------------------------------------------------- readonly property bool hovered: sliverHit.containsMouse || clock.hovered || status.hovered || media.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 === "calendar") clock.focusPanel(); else 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: if (!(win.panel === "calendar" && clock.back())) 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: receptor; intersection: Intersection.Combine } Region { item: clock; intersection: Intersection.Combine } Region { item: status; intersection: Intersection.Combine } Region { item: media.visible ? media : null; intersection: Intersection.Combine } } // The visible sliver: neutral at rest, the capture colours while // something captures, one equal segment per active kind. Not the accent // at rest: udt snaps the accent to a palette colour, and when it lands on // green an idle sliver read exactly like a live microphone. Row { id: sliver x: win.cx - width / 2 y: 0 width: 120 height: 4 // A handle, so it gives way to the pills it reveals. Not while // something captures: then it is the only privacy indicator there is. opacity: Privacy.roles.length ? 1 : 1 - win.reveal Repeater { model: Privacy.roles.length ? Privacy.roles : ["overlay"] 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 } // --- file drop --------------------------------------------------------- // A file dragged onto the sliver opens a bubble to drop it on; the drop // hands it to the assistant canvas. Wayland reports a drag only once it // enters an input region, so the receptor is the sliver's width at rest // and grows to hold the bubble while a drag is over it. property bool dropHold: false Item { id: receptor x: win.cx - width / 2 y: 0 z: 10 // The sliver's hit width at rest, so it takes no clicks beside it. width: win.dropHold ? 260 : 220 height: win.dropHold ? 100 : 10 DropArea { anchors.fill: parent enabled: win.asst !== null onEntered: drag => { drag.accepted = drag.hasUrls && (drag.supportedActions & Qt.CopyAction); if (drag.accepted) { win.dropHold = true; drag.accept(Qt.CopyAction); } } onExited: win.dropHold = false onDropped: drop => { win.dropHold = false; const paths = drop.urls.map(u => Files.fromUrl(u)).filter(p => p !== ""); if (!paths.length) { drop.accepted = false; return; } win.asst.attach(paths); drop.accept(Qt.CopyAction); } } Rectangle { visible: win.dropHold anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: 14 } width: parent.width height: 72 radius: 16 color: Qt.alpha(Theme.base, 0.9) border.width: 1 border.color: Qt.alpha(Theme.accent, 0.6) Text { anchors.centerIn: parent text: "Drop for the assistant" color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } } } // 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 panel: win.panel onOpenCalendar: win.panel = win.panel === "calendar" ? "" : "calendar" onCloseCalendar: win.panel = "" } 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"); } } // While something plays or is paused; it sits left of the clock pill and // follows it as the calendar widens it. MediaPill { id: media visible: Player.playing || Player.current?.playbackState === MprisPlaybackState.Paused x: win.cx - width / 2 - (win.gap * 1.5 + clock.width + width / 2) * win.reveal y: -height + (win.top + height) * win.reveal opacity: Math.min(1, win.reveal * 2) } }