diff --git a/hypr/lua/binds/shell.lua b/hypr/lua/binds/shell.lua index d309345..717aff2 100644 --- a/hypr/lua/binds/shell.lua +++ b/hypr/lua/binds/shell.lua @@ -9,6 +9,7 @@ end shell("lock", { key = mod .. " + CTRL + L", description = "lock screen" }) shell("power", { key = mod .. " + P", description = "power menu" }) shell("ask", { key = mod .. " + A", description = "ask claude" }) +shell("dashboard", { key = mod .. " + I", description = "dashboard" }) shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" }) shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" }) shell("reloadConfig", { key = mod .. " + SHIFT + R", description = "reload shell config" }) diff --git a/quickshell/README.md b/quickshell/README.md index bf6ec6d..04ad801 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -57,7 +57,7 @@ clear composition and stronger separation from the desktop. **Cinematic** it is never used as decoration inside ordinary chrome or popouts. ``` -shell.qml entrypoint — bar, rail, power menu, toasts, OSD per screen +shell.qml entrypoint — hull, bar, rail, dashboard, power menu, toasts, OSD per screen config/ palette, type scale, geometry, motion curves components/ the primitives: panels, sliders, list rows, display text services/ data sources, all singletons @@ -72,13 +72,24 @@ assets/ halftone and stripe tiles, generated with ImageMagick callers set implicit sizes from their own layout (`row.implicitWidth + contentPad * 2`), which keeps the shape maths out of binding loops. +## Dashboard + +`dashboard/Dashboard.qml` is a drawer that slides down out of the underside of +the bar — clock click, `SUPER + I`, or `qs ipc call shell dashboard`. It is +painted in the hull colour and joins the bar through two concave fillets, so it +reads as more of the hull rather than as a floating card (caelestia's idea). +Four columns divided by hairlines: operator and time, the month, the system +meters, and what is playing. Escape or focusing anything else puts it away; a +`HyprlandFocusGrab` handles the latter, and `Actions.dashboardClosedAt` stops +the clock click that cleared the grab from opening it straight back up. + ## Modules | Module | Where | Left click | Right click | Middle | Scroll | Hover | |---|---|---|---|---|---|---| | Sigil | bar | app menu | reload shell | | | | | Workspaces | bar | switch | | | | | -| Clock | bar | pin calendar | | | | calendar | +| Clock | bar | dashboard | | | | calendar | | Network | rail | pin details | | | | state + networks | | Bluetooth | rail | pin details | | | | state + devices | | Volume | rail | pin audio | | | volume | state + scroll hint + audio | diff --git a/quickshell/bar/Clock.qml b/quickshell/bar/Clock.qml index b2165ce..5ec53c6 100644 --- a/quickshell/bar/Clock.qml +++ b/quickshell/bar/Clock.qml @@ -3,8 +3,10 @@ import Quickshell import "root:/config" import "root:/components" import "root:/popouts" +import "root:/services" -// Centre-stage clock. Hovering drops the calendar. +// Centre-stage clock. Hovering drops the calendar; clicking drops the +// dashboard, which carries the calendar too. BarPill { id: root @@ -20,7 +22,7 @@ BarPill { precision: SystemClock.Minutes } - onClicked: calendar.pinned = !calendar.pinned + onClicked: Actions.toggleDashboard() Row { id: layout @@ -116,7 +118,8 @@ BarPill { CalendarPopout { id: calendar anchorItem: root - triggerHovered: root.hovered + // Not while the dashboard is down: it already shows the month. + triggerHovered: root.hovered && !Actions.dashboardOpen clockDate: clock.date } } diff --git a/quickshell/dashboard/Dashboard.qml b/quickshell/dashboard/Dashboard.qml new file mode 100644 index 0000000..5006bdf --- /dev/null +++ b/quickshell/dashboard/Dashboard.qml @@ -0,0 +1,636 @@ +import QtQuick +import QtQuick.Shapes +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Hyprland +import "root:/config" +import "root:/components" +import "root:/services" + +// The dashboard: a drawer that slides down out of the underside of the bar. +// +// The idea is caelestia's — the drawer is not a floating card but more of the +// hull, so it is painted in the hull colour and joins the bar through two +// concave fillets, and the porthole's rim runs round it without a break. Read +// against the wallpaper it is a panel lowered from the ceiling of the ship. +// +// Four columns divided by hairlines: who and when, the month, the machine, +// and what is playing. The clock in the bar toggles it, as does +// `qs ipc call shell dashboard`; Escape or a click anywhere else puts it away. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + readonly property bool open: Actions.dashboardOpen + && Actions.dashboardScreen === modelData.name + + // 0 = tucked under the bar, 1 = fully down. + property real reveal: root.open ? 1 : 0 + Behavior on reveal { + NumberAnimation { + duration: Theme.durSlow + easing.type: root.open ? Easing.OutCubic : Easing.InCubic + } + } + + visible: root.open || root.reveal > 0 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "takemi-dashboard" + WlrLayershell.keyboardFocus: root.open + ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + + // One pixel up, so the drawer covers the rim the hull draws along the + // underside of the bar instead of sitting below it. + anchors.top: true + margins.top: Theme.barHeight - 1 + + // The fillets are the same radius as the porthole's corners, so the drawer + // meets the bar the way the opening meets the frame. + readonly property real fillet: Theme.frameRadius + readonly property real radius: Theme.frameRadius + readonly property real pad: Theme.padL + + readonly property real bodyWidth: columns.implicitWidth + root.pad * 2 + readonly property real bodyHeight: columns.implicitHeight + root.pad * 2 + + implicitWidth: root.bodyWidth + root.fillet * 2 + implicitHeight: root.bodyHeight + 1 + color: "transparent" + + mask: Region { item: body } + + HyprlandFocusGrab { + active: root.open + windows: [root] + onCleared: Actions.closeDashboard() + } + + // Who is at the console. Read once; it does not change under a session. + readonly property string user: Quickshell.env("USER") ?? "" + FileView { + id: hostFile + path: "/etc/hostname" + } + readonly property string host: hostFile.text().trim() + + SystemClock { + id: clock + precision: SystemClock.Seconds + } + + // Everything below slides as one sheet, clipped to the window so it + // appears to come out from behind the bar rather than fading in over it. + Item { + anchors.fill: parent + clip: true + + Item { + id: sheet + width: parent.width + height: parent.height + y: -height * (1 - root.reveal) + + // ------------------------------------------------------ silhouette + Shape { + id: silhouette + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + readonly property real w: width + readonly property real h: height + readonly property real f: root.fillet + readonly property real r: root.radius + + // Down the left fillet, round the bottom, up the right fillet. + readonly property string edge: + `M 0 0 A ${f} ${f} 0 0 1 ${f} ${f}` + + ` V ${h - r} A ${r} ${r} 0 0 0 ${f + r} ${h}` + + ` H ${w - f - r} A ${r} ${r} 0 0 0 ${w - f} ${h - r}` + + ` V ${f} A ${f} ${f} 0 0 1 ${w} 0` + + ShapePath { + fillColor: Theme.hull + strokeColor: "transparent" + PathSvg { path: silhouette.edge + " Z" } + } + + // The rim, carried on round the drawer from the opening. + ShapePath { + fillColor: "transparent" + strokeColor: Theme.rim + strokeWidth: 1 + PathSvg { path: silhouette.edge } + } + } + + // ---------------------------------------------------------- body + FocusScope { + id: body + x: root.fillet + width: root.bodyWidth + height: parent.height + focus: root.open + + Keys.onEscapePressed: Actions.closeDashboard() + + Row { + id: columns + x: root.pad + y: root.pad + spacing: 0 + + // Who and when: the house mark, the operator, a large clock. + Item { + implicitWidth: 230 + implicitHeight: root.columnHeight + + Row { + id: who + spacing: Theme.padM + + Porthole { + anchors.verticalCenter: parent.verticalCenter + size: 30 + lit: true + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: root.user.toUpperCase() + color: Theme.text + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsBody + font.weight: Theme.weightDisplay + font.letterSpacing: Theme.tracking + renderType: Text.NativeRendering + } + + Text { + text: root.host !== "" ? "@ " + root.host : "" + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 1 + renderType: Text.NativeRendering + } + } + } + + Row { + id: time + anchors.top: who.bottom + anchors.topMargin: Theme.padL + spacing: Theme.padS + + SplitText { + id: hhmm + text: Qt.formatDateTime(clock.date, "HH:mm") + pixelSize: 54 + weight: Font.Light + letterSpacing: 2 + color: Theme.accent + } + + Text { + anchors.baseline: hhmm.bottom + anchors.baselineOffset: -12 + text: Qt.formatDateTime(clock.date, "ss") + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsBody + renderType: Text.NativeRendering + } + } + + Text { + anchors.top: time.bottom + anchors.topMargin: Theme.padS + text: Qt.formatDateTime(clock.date, "dddd · dd MMMM").toUpperCase() + color: Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + Column { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.padS + + Fact { label: "UPTIME"; value: Sys.uptime || "—" } + Fact { + visible: Battery.available + label: "POWER" + value: Math.round(Battery.percent) + "%" + + (Battery.timeLabel !== "" ? " · " + Battery.timeLabel : "") + } + Fact { label: "NOTICES"; value: Notifs.count > 0 ? Notifs.count + " held" : "none" } + } + } + + Rule { height: root.columnHeight } + + // The month, Monday first. Today is the NieR cursor: an inverted square. + Item { + id: month + implicitWidth: 238 + implicitHeight: root.columnHeight + + // Keyed by the day, not the clock: the clock ticks every + // second, and rebuilding 42 cells that often is waste. + readonly property string dayKey: Qt.formatDateTime(clock.date, "yyyy-MM-dd") + readonly property var cells: { + const now = new Date(month.dayKey + "T12:00:00"); + const y = now.getFullYear(), m = now.getMonth(); + const lead = (new Date(y, m, 1).getDay() + 6) % 7; + const out = []; + for (let i = 0; i < 42; i++) { + const d = new Date(y, m, 1 - lead + i); + out.push({ + day: d.getDate(), + inMonth: d.getMonth() === m, + today: d.toDateString() === now.toDateString() + }); + } + return out; + } + + SectionRule { + id: monthHead + anchors.left: parent.left + anchors.right: parent.right + text: Qt.formatDateTime(clock.date, "MMMM yyyy") + } + + Row { + id: weekdays + anchors.top: monthHead.bottom + anchors.topMargin: Theme.padS + + Repeater { + model: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] + + Text { + required property var modelData + required property int index + width: month.width / 7 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: index >= 5 ? Theme.subtext : Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + } + } + + Grid { + anchors.top: weekdays.bottom + anchors.topMargin: Theme.padS + columns: 7 + + Repeater { + model: month.cells + + Item { + required property var modelData + width: month.width / 7 + height: 26 + + Rectangle { + anchors.centerIn: parent + width: 22 + height: 20 + visible: parent.modelData.today + color: Theme.treatmentSelectedFill + } + + Text { + anchors.centerIn: parent + text: parent.modelData.day + color: parent.modelData.today ? Theme.treatmentSelectedText + : parent.modelData.inMonth ? Theme.text + : Theme.alpha(Theme.muted, 0.5) + font.family: Theme.fontMono + font.pixelSize: Theme.fsSmall + font.weight: parent.modelData.today ? Font.Bold : Font.Normal + renderType: Text.NativeRendering + } + } + } + } + } + + Rule { height: root.columnHeight } + + // The machine: the same meters the bar's readout opens onto. + Item { + implicitWidth: 238 + implicitHeight: root.columnHeight + + SectionRule { + id: sysHead + anchors.left: parent.left + anchors.right: parent.right + text: "System" + } + + Column { + anchors.top: sysHead.bottom + anchors.topMargin: Theme.padM + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.padM + + MetricRow { + width: parent.width + value: Sys.cpuUsage + label: "CPU" + sub: Math.round(Sys.cpuTemp) + "°C" + } + + MetricRow { + width: parent.width + value: Sys.memPercent + label: "MEM" + sub: Sys.gb(Sys.memUsed) + " / " + Sys.gb(Sys.memTotal) + } + + MetricRow { + width: parent.width + visible: Sys.gpuAvailable + value: Sys.gpuUsage + label: "GPU" + sub: Math.round(Sys.gpuTemp) + "°C" + } + + MetricRow { + width: parent.width + value: Sys.diskPercent + label: "DISK" + sub: Sys.diskFree + " free" + } + } + } + + Rule { height: root.columnHeight } + + // What is playing. With no player it says so, in the voice NieR's + // interface uses for an empty channel. + Item { + implicitWidth: 280 + implicitHeight: root.columnHeight + + SectionRule { + id: playHead + anchors.left: parent.left + anchors.right: parent.right + text: Media.hasPlayer ? Media.identity : "Audio" + } + + Text { + anchors.centerIn: parent + visible: !Media.hasPlayer + text: "NO SIGNAL" + color: Theme.muted + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsBody + font.letterSpacing: Theme.tracking * 2 + renderType: Text.NativeRendering + } + + Item { + anchors.top: playHead.bottom + anchors.topMargin: Theme.padM + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: Media.hasPlayer + + Rectangle { + id: artFrame + width: 96 + height: 96 + color: Theme.surface + border.width: 1 + border.color: Theme.overlay + + Image { + id: art + anchors.fill: parent + anchors.margins: 1 + source: Media.artUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + sourceSize.width: 192 + sourceSize.height: 192 + } + + Icon { + anchors.centerIn: parent + visible: art.status !== Image.Ready + text: "󰝚" + color: Theme.muted + font.pixelSize: Theme.fsHuge + } + } + + Column { + anchors.left: artFrame.right + anchors.leftMargin: Theme.padM + anchors.right: parent.right + anchors.verticalCenter: artFrame.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: Media.title || "Untitled" + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.Wrap + color: Theme.accent + font.family: Theme.fontDisplay + font.pixelSize: Theme.fsLarge + font.weight: Theme.weightDisplay + renderType: Text.NativeRendering + } + + Text { + width: parent.width + text: Media.artist + elide: Text.ElideRight + color: Theme.subtext + font.family: Theme.fontMono + font.pixelSize: Theme.fsSmall + renderType: Text.NativeRendering + } + } + + // Position: a hairline with a square riding it. + Item { + id: timeline + anchors.top: artFrame.bottom + anchors.topMargin: Theme.padM + anchors.left: parent.left + anchors.right: parent.right + height: Theme.marker + visible: Media.hasTimeline + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 1 + color: Theme.overlay + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width * Media.progress + height: 1 + color: Theme.accent + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + x: parent.width * Media.progress - width / 2 + width: Theme.marker + height: Theme.marker + color: Theme.accent + } + } + + Row { + anchors.bottom: parent.bottom + anchors.left: parent.left + spacing: Theme.padS + + Transport { + glyph: "󰒮" + enabled: Media.canGoPrevious + onClicked: Media.previous() + } + Transport { + glyph: Media.playing ? "󰏤" : "󰐊" + onClicked: Media.playPause() + } + Transport { + glyph: "󰒭" + enabled: Media.canGoNext + onClicked: Media.next() + } + } + + Text { + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + anchors.right: parent.right + visible: Media.hasTimeline + text: Media.timeString(Media.position) + " / " + Media.timeString(Media.length) + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + } + } + } + } + } + } + + // ------------------------------------------------------------ columns + // A vertical hairline between columns, the same rule the bar divides its + // modules with, at the drawer's scale. + component Rule: Item { + implicitWidth: Theme.padL * 2 + 1 + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: Theme.padS + anchors.bottomMargin: Theme.padS + width: 1 + color: Theme.alpha(Theme.outline, 0.5) + } + } + + readonly property real columnHeight: 214 + + // A label and its reading on one line, label left, reading right. + component Fact: Item { + property string label: "" + property string value: "" + + width: parent.width + implicitHeight: 14 + + Text { + anchors.left: parent.left + text: parent.label + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + Text { + anchors.right: parent.right + text: parent.value + color: Theme.text + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + } + + // A square hairline button. Hover lifts it; nothing here inverts, since + // inversion is reserved for the keyboard's cursor. + component Transport: Item { + id: tb + property string glyph: "" + signal clicked() + + width: 30 + height: 30 + opacity: tb.enabled ? 1 : 0.35 + + Rectangle { + anchors.fill: parent + color: tbHover.hovered ? Theme.treatmentHoverFill : "transparent" + border.width: 1 + border.color: tbHover.hovered ? Theme.treatmentHoverMark : Theme.overlay + + Behavior on color { ColorAnimation { duration: Theme.durFast } } + } + + Icon { + anchors.centerIn: parent + text: tb.glyph + color: tbHover.hovered ? Theme.accent : Theme.text + } + + HoverHandler { + id: tbHover + enabled: tb.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: tb.enabled + onTapped: tb.clicked() + } + } +} diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml index c316438..fd30c3d 100644 --- a/quickshell/services/Actions.qml +++ b/quickshell/services/Actions.qml @@ -18,6 +18,7 @@ Singleton { property bool sidebarOpen: false property bool launcherOpen: false property bool askOpen: false + property bool dashboardOpen: false // Seed text for the next launcher open. Lets a keybind or a script hand the // launcher a query it did not have to type — `qs ipc call shell prompt @@ -36,6 +37,15 @@ Singleton { // …and for the ask panel, which also takes the keyboard. property string askScreen: "" + // …and for the dashboard drawer. + property string dashboardScreen: "" + + // When the dashboard was last closed from outside. A click on the bar's + // clock first clears the drawer's focus grab, which closes it, and then + // lands on the clock, which would toggle it straight back open. A toggle + // that arrives within a moment of a grab-close is that same click. + property real dashboardClosedAt: 0 + // Seed text for the next ask open. A seeded open asks straight away, so // `qs ipc call shell askAbout "$(wl-paste)"` is a one-shot question. property string askSeed: "" @@ -65,6 +75,19 @@ Singleton { } function closeLauncher() { root.launcherOpen = false; } + function toggleDashboard() { + if (!root.dashboardOpen && Date.now() - root.dashboardClosedAt < 250) + return; + if (!root.dashboardOpen) + root.dashboardScreen = root.focusedScreenName(); + root.dashboardOpen = !root.dashboardOpen; + } + function closeDashboard() { + if (!root.dashboardOpen) return; + root.dashboardOpen = false; + root.dashboardClosedAt = Date.now(); + } + function toggleAsk() { if (!root.askOpen) { root.askSeed = ""; @@ -131,6 +154,7 @@ Singleton { function power(): void { root.togglePowerMenu(); } function launcher(): void { root.toggleLauncher(); } function ask(): void { root.toggleAsk(); } + function dashboard(): void { root.toggleDashboard(); } function askAbout(question: string): void { root.askAbout(question); } function prompt(seed: string): void { root.promptLauncher(seed); } function dmenu(inPath: string, outPath: string, prompt: string): void { diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 284d5eb..315daa8 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -4,6 +4,7 @@ import "root:/config" import "root:/services" import "root:/bar" import "root:/launcher" +import "root:/dashboard" import "root:/ask" import "root:/overlays" import "root:/lock" @@ -14,6 +15,7 @@ import "root:/lock" // bar/ the hull, the top bar, the rail and their modules // popouts/ hover panels hanging off bar modules // launcher/ the application launcher +// dashboard/ the drawer that drops from the bar // ask/ the question panel, dropped from the top edge // overlays/ power menu, notification toasts, OSD // lock/ session lock @@ -60,6 +62,12 @@ ShellRoot { Launcher {} } + Variants { + model: Quickshell.screens + + Dashboard {} + } + Variants { model: Quickshell.screens