aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island/Island.qml
blob: dba995d4017688bc8ef1e1eb86563e8186e1210d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
// 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

    // "", "sound", "controls", "perf" or "calendar".
    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 === "calendar") clock.focusPanel();
        else if (panel !== "") keys.forceActiveFocus();
        else if (!hovered) hideTimer.restart();
    }

    // The keybind's way in: reveal, and open or close the controls panel.
    // Closing goes through onPanelChanged, which starts the hide timer when
    // the pointer is elsewhere.
    function toggleControls() {
        hideTimer.stop();
        shown = true;
        panel = panel === "controls" ? "" : "controls";
    }

    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: clock; intersection: Intersection.Combine }
        Region { item: status; 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
    }

    // 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"); }
        onOpenControls: win.toggleControls()
        onOpenPerf: win.panel = win.panel === "perf" ? "" : "perf"
        onOpenPage: page => { win.panel = ""; win.drawer.show(page); }
    }
}