diff --git a/plugins/launchpad/.gitignore b/plugins/launchpad/.gitignore new file mode 100644 index 0000000..b8eb3e3 --- /dev/null +++ b/plugins/launchpad/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +*.tmp diff --git a/plugins/launchpad/AGENTS.md b/plugins/launchpad/AGENTS.md new file mode 100644 index 0000000..49657a1 --- /dev/null +++ b/plugins/launchpad/AGENTS.md @@ -0,0 +1,84 @@ +# Agent guide for the launchpad plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new ` scaffolds this folder (already done). +2. Edit: `service/Main.qml` for logic, `content/Widget.qml` for the view, and + `content/Panel.qml` for the bar panel. Capture `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --bar --yes` to install it, then verify it on the bar + and under **QS Bar Settings > Community**. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Every entry point receives, from the host: `pluginApi` (see below), `s` (a scale +factor), `active`, `density`, and `widthBudget`. Read them; never assign them. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned widget click is + `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/launchpad/CLAUDE.md b/plugins/launchpad/CLAUDE.md new file mode 100644 index 0000000..3c35c32 --- /dev/null +++ b/plugins/launchpad/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +Read `AGENTS.md` in this folder before editing anything here. +It holds every rule this plugin must follow and the full authoring workflow. diff --git a/plugins/launchpad/LICENSE b/plugins/launchpad/LICENSE new file mode 100644 index 0000000..d98d35e --- /dev/null +++ b/plugins/launchpad/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 guyb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/launchpad/README.md b/plugins/launchpad/README.md new file mode 100644 index 0000000..3a7845e --- /dev/null +++ b/plugins/launchpad/README.md @@ -0,0 +1,70 @@ +# Launchpad + +A Ryoku bar plugin (`launchpad`): one tile mark on the QS Bar that opens a +pinboard panel. + +![panel](assets/preview-panel.png) ![edit mode](assets/preview-edit.png) + +- **Widgets**: the widgets you pick, as tabs: **Clock, Calendar, Music** (any + MPRIS player), **All-in-one** (clock + weather + visualizer), **System stats** + (CPU, memory, swap, load, uptime), **Weather** (now + 5 days), **Notes** and + **Visualizer** (a cava spectrum), plus every desktop-widget plugin you have + installed from Ryostore, mounted in the panel the way the desktop mounts it. +- **Pinned apps**: your favourite apps as a grid; click to launch. +- **Commands**: one-click chips that run a command. + +Click **EDIT** to change any of it: + +- every section (Widgets, Pinned, Commands) gets a grip to **drag** it into place + and a **HIDE / SHOW** pill; +- widgets, pinned apps and commands turn into lists: **drag** a row by its grip + to reorder, **×** to remove; +- **Pin an app** opens a search, the widget list offers every widget not yet + picked, and a label + command row adds a command. + +Everything is also in QS Bar Settings > Community > Launchpad. + +## What it runs, reads and writes + + commands, exactly as you typed them, in your terminal (`ryoku-app terminal --`), as an argv split on spaces (quotes group + commands, exactly as you typed them, as an argv split on spaces (quotes group + words). There is **no shell**, so pipes, `&&` and `$VARS` do nothing; point a + command at a script on your `PATH` if you need them. It also runs + `ryoku-shell icons` once (the icon index), and `cava -p /cava.conf` + only while the Visualizer or All-in-one tab is on screen. +- **Reads**: the installed desktop entries, `ryoku plugin list --json` (to find + desktop-widget plugins; their own code then runs inside the panel), the MPRIS + players on the session bus, the shell daemon's `weather` feed over its local + socket (`$XDG_RUNTIME_DIR/ryoku-shell.sock`; Ryoku does the fetch, so set + your location and units in Ryoku Hub), `/proc/loadavg`, `/proc/uptime`, `/proc/stat` and `/proc/meminfo` + (every 2 s, only while the panel is open). +- **Writes**: its own settings, through the bar (`pluginApi.saveSetting`), and + your notes and the cava config to `$XDG_STATE_HOME/ryoku/plugins/launchpad/`. A hosted + desktop-widget plugin writes only to its own state folder, as it does on the desktop. +- **Network**: none. **Privileged**: none. + +## Settings + +QS Bar Settings > Community > Launchpad. + +| key | type | default | description | +| ------------ | ------ | -------------------------------------------------------- | ----------------------------------------- | +| showWidgets | toggle | true | Show the Widgets section | +| showPinned | toggle | true | Show the Pinned section | +| showCommands | toggle | true | Show the Commands section | +| sectionOrder | text | `widgets pinned commands` | Section order | +| widgetList | text | `calendar music notes` | Widget tabs in order: `clock calendar music aio stats weather notes visualizer`, `plugin:` for a store widget | +| apps | text | (empty) | Pinned desktop ids, space-separated | +| columns | int | 5 | App grid columns (3–8) | +| commands | text | `Lock=loginctl lock-session ; Suspend=systemctl suspend` | `Label=command` pairs split by ` ; ` | + +## Install + +``` +ryoku plugin validate . +ryoku plugin add . --bar --yes +``` + +## Author + +guyb . Community-made (`official` is false). MIT licence. diff --git a/plugins/launchpad/assets/preview-edit.png b/plugins/launchpad/assets/preview-edit.png new file mode 100644 index 0000000..558c421 Binary files /dev/null and b/plugins/launchpad/assets/preview-edit.png differ diff --git a/plugins/launchpad/assets/preview-panel.png b/plugins/launchpad/assets/preview-panel.png new file mode 100644 index 0000000..56b49fc Binary files /dev/null and b/plugins/launchpad/assets/preview-panel.png differ diff --git a/plugins/launchpad/assets/preview-widget.png b/plugins/launchpad/assets/preview-widget.png new file mode 100644 index 0000000..1eb4a02 Binary files /dev/null and b/plugins/launchpad/assets/preview-widget.png differ diff --git a/plugins/launchpad/content/DeskWidgets.qml b/plugins/launchpad/content/DeskWidgets.qml new file mode 100644 index 0000000..20ff979 --- /dev/null +++ b/plugins/launchpad/content/DeskWidgets.qml @@ -0,0 +1,610 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// The panel's Widgets section: the widgets you picked, as tabs, one mounted at a time. +// Clock, Calendar, Music, All-in-one, System stats, Weather, Notes and Visualizer are +// drawn here; a picked desktop-widget plugin is mounted the way the desktop mounts it. +// EDIT shows the picked list (drag to reorder, × to remove) and every widget you can add. +Column { + id: dw + + property var service + property real s: 1 + property bool active: false + property bool editing: false + + readonly property var tabs: service ? service.chosenWidgets : [] + property string current: "" + readonly property var tab: tabs.find(t => t.key === current) || tabs[0] || null + + spacing: 12 * s + + // ---- EDIT: picked widgets in order, then the ones you can add --------- + OrderList { + visible: dw.editing + width: dw.width + s: dw.s + model: dw.editing ? dw.tabs : [] + labelOf: (w) => w.label + subOf: (w) => w.plugin ? "store widget" : "" + onMoveTo: (from, to) => dw.service.moveWidget(from, to) + onRemove: (i) => dw.service.toggleWidget(dw.tabs[i].key) + } + Flow { + visible: dw.editing + width: dw.width + spacing: 6 * dw.s + Repeater { + model: dw.editing && dw.service ? dw.service.availableWidgets.filter(w => !dw.service.widgetKeys.includes(w.key)) : [] + delegate: Pill { + required property var modelData + label: modelData.label + glyph: "install" + onClicked: dw.service.toggleWidget(modelData.key) + } + } + } + + Text { + visible: !dw.editing && dw.tabs.length === 0 + text: "No widgets yet. EDIT to pick some." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 11.5 * dw.s + } + + // ---- tab row ---------------------------------------------------------- + Flow { + visible: !dw.editing && dw.tabs.length > 1 + width: dw.width + spacing: 6 * dw.s + Repeater { + model: dw.tabs + delegate: Pill { + required property var modelData + label: modelData.label + on: dw.tab && dw.tab.key === modelData.key + onClicked: dw.current = modelData.key + } + } + } + + // ---- the mounted widget ----------------------------------------------- + Loader { + visible: !dw.editing + width: dw.width + active: dw.active && !dw.editing && !!dw.tab + sourceComponent: !dw.tab ? null + : dw.tab.plugin ? hostedView + : ({ clock: clockView, calendar: calendarView, music: musicView, aio: aioView, + stats: statsView, weather: weatherView, notes: notesView, visualizer: vizView })[dw.tab.key] || null + } + + // ====================================================================== + + component Pill: Rectangle { + id: pill + property string label + property string glyph: "" + property bool on: false + signal clicked() + width: pillRow.implicitWidth + 20 * dw.s + height: 26 * dw.s + radius: height / 2 + color: on ? Theme.threadBg : pillArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: on || pillArea.containsMouse ? Theme.accent : Theme.hair + Behavior on color { ColorAnimation { duration: Motion.fast } } + Row { + id: pillRow + anchors.centerIn: parent + spacing: 6 * dw.s + GlyphIcon { + visible: pill.glyph.length > 0 + anchors.verticalCenter: parent.verticalCenter + width: 10 * dw.s + height: 10 * dw.s + name: pill.glyph + color: pillArea.containsMouse ? Theme.accent : Theme.iconDim + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: pill.label + color: pill.on ? Theme.accent : Theme.dim + font.family: Theme.mono + font.pixelSize: 10.5 * dw.s + } + } + MouseArea { + id: pillArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: pill.clicked() + } + } + + component Stat: Column { + id: st + property string label + property string value + property real frac: -1 // 0..1 draws a meter + width: dw.width + spacing: 5 * dw.s + Item { + width: st.width + height: stLabel.implicitHeight + Text { id: stLabel; text: st.label; color: Theme.faint; font.family: Theme.mono; font.pixelSize: 9.5 * dw.s; font.letterSpacing: 1.6 * dw.s } + Text { anchors.right: parent.right; text: st.value; color: Theme.bright; font.family: Theme.mono; font.pixelSize: 10.5 * dw.s } + } + Rectangle { + visible: st.frac >= 0 + width: st.width + height: 3 * dw.s + radius: height / 2 + color: Theme.hair + Rectangle { + width: parent.width * Math.max(0, Math.min(1, st.frac)) + height: parent.height + radius: parent.radius + color: st.frac > 0.85 ? Theme.verm : Theme.accent + Behavior on width { NumberAnimation { duration: Motion.standard; easing.type: Motion.easeStandard } } + } + } + } + + // ---- clock ------------------------------------------------------------ + component ClockFace: Column { + id: cf + property real size: 54 + SystemClock { id: now; precision: SystemClock.Minutes } + spacing: 2 * dw.s + Text { + text: Qt.formatTime(now.date, "HH:mm") + color: Theme.bright + font.family: Theme.display + font.pixelSize: cf.size * dw.s + } + Text { + text: Qt.formatDate(now.date, "dddd · d MMMM").toLowerCase() + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 11 * dw.s + } + } + Component { id: clockView; ClockFace {} } + + // ---- calendar --------------------------------------------------------- + Component { + id: calendarView + Column { + id: cal + spacing: 8 * dw.s + SystemClock { id: today; precision: SystemClock.Hours } + property int offset: 0 + readonly property date month: new Date(today.date.getFullYear(), today.date.getMonth() + offset, 1) + // Monday-first grid of 42 days covering the month + readonly property var days: { + const first = new Date(month); + first.setDate(1 - (month.getDay() + 6) % 7); + return Array.from({ length: 42 }, (_, i) => new Date(first.getFullYear(), first.getMonth(), first.getDate() + i)); + } + Item { + width: cal.width + height: monthTitle.implicitHeight + Text { + id: monthTitle + text: Qt.formatDate(cal.month, "MMMM yyyy") + color: Theme.bright + font.family: Theme.display + font.pixelSize: 16 * dw.s + } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 * dw.s + Repeater { + model: [{ g: "chevron-left", d: -1 }, { g: "chevron-right", d: 1 }] + delegate: Rectangle { + id: nav + required property var modelData + width: 24 * dw.s + height: 24 * dw.s + radius: width / 2 + color: navArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: navArea.containsMouse ? Theme.accent : Theme.hair + GlyphIcon { anchors.centerIn: parent; width: 12 * dw.s; height: 12 * dw.s; name: nav.modelData.g; color: Theme.dim } + MouseArea { id: navArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: cal.offset += nav.modelData.d } + } + } + } + } + Grid { + columns: 7 + width: cal.width + readonly property real cell: width / 7 + Repeater { + model: ["M", "T", "W", "T", "F", "S", "S"] + delegate: Text { + required property string modelData + width: parent.cell + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 9.5 * dw.s + } + } + Repeater { + model: cal.days + delegate: Item { + id: day + required property var modelData + readonly property bool inMonth: modelData.getMonth() === cal.month.getMonth() + readonly property bool isToday: modelData.toDateString() === today.date.toDateString() + width: parent.cell + height: 26 * dw.s + Rectangle { + anchors.centerIn: parent + width: 24 * dw.s + height: 24 * dw.s + radius: width / 2 + color: day.isToday ? Theme.accent : "transparent" + } + Text { + anchors.centerIn: parent + text: day.modelData.getDate() + color: day.isToday ? Theme.cardBot : day.inMonth ? Theme.bright : Theme.ghost + font.family: Theme.mono + font.pixelSize: 11 * dw.s + } + } + } + } + } + } + + // ---- music ------------------------------------------------------------ + Component { + id: musicView + Item { + id: media + readonly property var player: Mpris.players.values.find(p => p.isPlaying) || Mpris.players.values[0] || null + height: 72 * dw.s + + Text { + visible: !media.player + anchors.verticalCenter: parent.verticalCenter + text: "Nothing is playing." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 12 * dw.s + } + Rectangle { + id: art + visible: !!media.player + width: 72 * dw.s + height: 72 * dw.s + radius: Motion.rSmall * dw.s + color: Theme.tileBg + clip: true + Image { + anchors.fill: parent + source: media.player ? media.player.trackArtUrl : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + GlyphIcon { + visible: !media.player || !media.player.trackArtUrl + anchors.centerIn: parent + width: 26 * dw.s + height: 26 * dw.s + name: "music" + color: Theme.iconDim + } + } + Column { + visible: !!media.player + anchors.left: art.right + anchors.leftMargin: 14 * dw.s + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 3 * dw.s + Text { + width: parent.width + text: media.player ? (media.player.trackTitle || media.player.identity) : "" + color: Theme.bright + font.family: Theme.font + font.pixelSize: 13.5 * dw.s + elide: Text.ElideRight + } + Text { + width: parent.width + text: media.player ? (media.player.trackArtist || "") : "" + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 10.5 * dw.s + elide: Text.ElideRight + } + Row { + topPadding: 4 * dw.s + spacing: 8 * dw.s + Repeater { + model: media.player ? [ + { g: "prev", ok: media.player.canGoPrevious, act: () => media.player.previous() }, + { g: media.player.isPlaying ? "pause" : "play", ok: media.player.canTogglePlaying, act: () => media.player.togglePlaying(), main: true }, + { g: "next", ok: media.player.canGoNext, act: () => media.player.next() } + ] : [] + delegate: Rectangle { + id: btn + required property var modelData + width: 28 * dw.s + height: 28 * dw.s + radius: width / 2 + opacity: modelData.ok ? 1 : 0.4 + color: modelData.main ? (btnArea.containsMouse ? Theme.vermLit : Theme.accent) + : btnArea.containsMouse ? Theme.sheen : "transparent" + border.width: modelData.main ? 0 : 1 + border.color: Theme.hair + GlyphIcon { + anchors.centerIn: parent + width: 11 * dw.s + height: 11 * dw.s + name: btn.modelData.g + color: btn.modelData.main ? Theme.cardBot : Theme.dim + } + MouseArea { + id: btnArea + anchors.fill: parent + enabled: btn.modelData.ok + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: btn.modelData.act() + } + } + } + } + } + } + } + + // ---- weather ---------------------------------------------------------- + component WeatherFace: Column { + id: wf + property bool compact: false + readonly property var w: dw.service ? dw.service.weather : null + readonly property var cur: w && w.current ? w.current : null + function glyph(code, isDay) { + if (code === 0) return isDay === false ? "moon" : "sun"; + if (code === 45 || code === 48) return "fog"; + if (code >= 95) return "storm"; + if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow"; + if (code >= 51) return "rain"; + return "cloud"; + } + spacing: 10 * dw.s + + Text { + visible: !wf.cur + text: wf.w && wf.w.status === "error" ? "Weather is unavailable." : "Loading weather…" + color: Theme.faint + font.family: Theme.font + font.pixelSize: 12 * dw.s + } + Row { + visible: !!wf.cur + spacing: 14 * dw.s + GlyphIcon { + anchors.verticalCenter: parent.verticalCenter + width: (wf.compact ? 28 : 40) * dw.s + height: width + name: wf.cur ? wf.glyph(wf.cur.code, wf.cur.isDay) : "cloud" + color: Theme.accent + } + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 1 * dw.s + Text { + text: wf.cur ? wf.cur.temperature : "" + color: Theme.bright + font.family: Theme.display + font.pixelSize: (wf.compact ? 22 : 30) * dw.s + } + Text { + text: wf.cur ? (wf.cur.condition + " · " + (wf.w.city || "")).toLowerCase() : "" + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 10.5 * dw.s + } + } + } + Text { + visible: !!wf.cur && !wf.compact + text: wf.cur ? "feels " + wf.cur.feelsLike + " · ↑ " + wf.cur.high + " ↓ " + wf.cur.low + " · " + wf.cur.humidity + "% humidity" : "" + color: Theme.dim + font.family: Theme.mono + font.pixelSize: 10 * dw.s + } + Row { + visible: !!wf.cur && !wf.compact + width: dw.width + Repeater { + model: wf.w && wf.w.daily ? wf.w.daily.slice(0, 5) : [] + delegate: Column { + id: dayCol + required property var modelData + width: dw.width / 5 + spacing: 4 * dw.s + Text { anchors.horizontalCenter: parent.horizontalCenter; text: dayCol.modelData.weekday; color: Theme.faint; font.family: Theme.mono; font.pixelSize: 9.5 * dw.s } + GlyphIcon { anchors.horizontalCenter: parent.horizontalCenter; width: 16 * dw.s; height: 16 * dw.s; name: wf.glyph(dayCol.modelData.code, true); color: Theme.iconDim } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: dayCol.modelData.hi + "° " + dayCol.modelData.lo + "°"; color: Theme.dim; font.family: Theme.mono; font.pixelSize: 10 * dw.s } + } + } + } + } + Component { id: weatherView; WeatherFace {} } + + // ---- visualizer ------------------------------------------------------- + component Viz: Item { + id: vz + readonly property var bars: dw.service ? dw.service.bars : [] + height: 80 * dw.s + Component.onCompleted: if (dw.service) dw.service.vizUsers++ + Component.onDestruction: if (dw.service) dw.service.vizUsers-- + Row { + anchors.bottom: parent.bottom + width: parent.width + spacing: 3 * dw.s + Repeater { + model: 32 + delegate: Rectangle { + required property int index + width: (vz.width - 31 * 3 * dw.s) / 32 + height: Math.max(2 * dw.s, vz.height * (vz.bars[index] || 0)) + anchors.bottom: parent.bottom + radius: Math.min(width / 2, 2 * dw.s) + color: Theme.accent + opacity: 0.45 + 0.55 * (vz.bars[index] || 0) + Behavior on height { NumberAnimation { duration: 60 } } + } + } + } + Text { + visible: vz.bars.length === 0 + anchors.centerIn: parent + text: "Play something to see it move." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 11.5 * dw.s + } + } + Component { id: vizView; Viz {} } + + // ---- all-in-one: clock + weather + visualizer -------------------------- + Component { + id: aioView + Column { + spacing: 14 * dw.s + Item { + width: parent.width + height: Math.max(aioClock.implicitHeight, aioWx.implicitHeight) + ClockFace { id: aioClock; size: 44 } + WeatherFace { id: aioWx; compact: true; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter } + } + Viz { width: parent.width; height: 56 * dw.s } + } + } + + // ---- system stats ----------------------------------------------------- + Component { + id: statsView + Column { + readonly property var svc: dw.service + function dur(sec) { + const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60); + return (d ? d + "d " : "") + (d || h ? h + "h " : "") + m + "m"; + } + spacing: 12 * dw.s + Stat { label: "CPU"; value: Math.round(parent.svc.cpu * 100) + "%"; frac: parent.svc.cpu } + Stat { label: "MEMORY"; value: parent.svc.memText; frac: parent.svc.mem } + Stat { label: "SWAP"; value: Math.round(parent.svc.swap * 100) + "%"; frac: parent.svc.swap } + Stat { label: "LOAD"; value: parent.svc.load.map(x => x.toFixed(2)).join(" ") } + Stat { label: "UPTIME"; value: parent.dur(parent.svc.uptime) } + } + } + + // ---- notes ------------------------------------------------------------ + Component { + id: notesView + Rectangle { + height: 150 * dw.s + radius: Motion.rSmall * dw.s + color: "transparent" + border.width: 1 + border.color: notesEdit.activeFocus ? Theme.accent : Theme.border + Flickable { + id: notesFlick + anchors.fill: parent + anchors.margins: 10 * dw.s + contentHeight: notesEdit.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + TextEdit { + id: notesEdit + width: notesFlick.width + wrapMode: TextEdit.Wrap + color: Theme.bright + selectionColor: Theme.accent + selectedTextColor: Theme.cardBot + font.family: Theme.mono + font.pixelSize: 11.5 * dw.s + text: dw.service ? dw.service.notes : "" + onTextChanged: if (dw.service && activeFocus) dw.service.setNotes(text) + onCursorRectangleChanged: { + if (cursorRectangle.y < notesFlick.contentY) notesFlick.contentY = cursorRectangle.y; + else if (cursorRectangle.y + cursorRectangle.height > notesFlick.contentY + notesFlick.height) + notesFlick.contentY = cursorRectangle.y + cursorRectangle.height - notesFlick.height; + } + Text { + visible: !notesEdit.text && !notesEdit.activeFocus + text: "Jot something down…" + color: Theme.faint + font: notesEdit.font + } + } + } + } + } + + // ---- an installed desktop-widget plugin ------------------------------- + Component { + id: hostedView + Item { + id: hosted + readonly property var plugin: dw.tab ? dw.tab.plugin : null + readonly property string dir: plugin ? plugin.dir : "" + height: slot.item ? Math.min(slot.item.implicitHeight || 200 * dw.s, 420 * dw.s) : 0 + clip: true + + // the handle a desktop plugin expects (as the desktop host gives it); its + // settings fall back to its own defaults, and saveSetting is inert here. + QtObject { + id: api + property var mainInstance: hostedSvc.item + readonly property var pluginSettings: ({}) + readonly property string pluginDir: hosted.dir + readonly property string stateDir: hosted.plugin + ? (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/ryoku/plugins/" + hosted.plugin.id : "" + function saveSetting(key, value) {} + function saveSettings() {} + } + PluginObjectSlot { + id: hostedSvc + source: hosted.dir ? "file://" + hosted.dir + "/service/Main.qml" : "" + configure: (svc) => { svc.pluginApi = api; } + } + PluginObjectSlot { + id: slot + width: hosted.width + height: hosted.height + fill: true + source: hosted.dir ? "file://" + hosted.dir + "/content/Widget.qml" : "" + configure: (it) => { + try { + it.pluginApi = api; + it.density = "compact"; + it.s = dw.s; + it.widthBudget = hosted.width; + it.active = true; + } catch (e) {} + } + } + } + } +} diff --git a/plugins/launchpad/content/OrderList.qml b/plugins/launchpad/content/OrderList.qml new file mode 100644 index 0000000..da08db2 --- /dev/null +++ b/plugins/launchpad/content/OrderList.qml @@ -0,0 +1,158 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// An EDIT list you reorder by dragging a row's grip: the row follows the pointer, the +// rows it passes slide out of its way, and it lands where you let go. × removes a row. +// labelOf / subOf / iconOf map a model entry to its text and icon. +Column { + id: list + + property var model: [] + property real s: 1 + property var labelOf: (e) => String(e) + property var subOf: (e) => "" + property var iconOf: (e) => "" + signal moveTo(int from, int to) + signal remove(int index) + + // the drag in flight: which row, and where it would land + property int dragFrom: -1 + property int dragTo: -1 + readonly property real pitch: 34 * s + spacing + + spacing: 6 * s + + Repeater { + model: list.model + delegate: Rectangle { + id: row + required property var modelData + required property int index + readonly property bool dragging: grip.drag.active + readonly property string icon: list.iconOf(modelData) || "" + readonly property string sub: list.subOf(modelData) || "" + // rows between the dragged row and its landing spot step aside + readonly property real shift: list.dragFrom < 0 || dragging ? 0 + : (index > list.dragFrom && index <= list.dragTo) ? -list.pitch + : (index < list.dragFrom && index >= list.dragTo) ? list.pitch : 0 + + width: list.width + height: 34 * list.s + radius: Motion.rSmall * list.s + z: dragging ? 10 : 0 + color: dragging ? Theme.threadBg : hover.hovered ? Theme.sheen : Theme.cardBot + border.width: 1 + border.color: dragging ? Theme.accent : Theme.hair + transform: Translate { + y: row.dragging ? grip.drag.translation.y : row.shift + Behavior on y { enabled: !row.dragging; NumberAnimation { duration: Motion.fast; easing.type: Motion.easeStandard } } + } + HoverHandler { id: hover } + + // grip: the drag handle + Item { + id: grip + property alias drag: dragH + width: 28 * list.s + height: parent.height + Column { + anchors.centerIn: parent + spacing: 2.5 * list.s + Repeater { + model: 3 + Rectangle { + width: 10 * list.s + height: 1.5 * list.s + radius: height / 2 + color: row.dragging || gripHover.hovered ? Theme.accent : Theme.iconDim + } + } + } + HoverHandler { id: gripHover; cursorShape: row.dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor } + DragHandler { + id: dragH + target: null + xAxis.enabled: false + onTranslationChanged: if (active) { + list.dragFrom = row.index; + list.dragTo = Math.max(0, Math.min(list.model.length - 1, row.index + Math.round(translation.y / list.pitch))); + } + onActiveChanged: if (!active) { + const from = list.dragFrom, to = list.dragTo; + list.dragFrom = -1; + list.dragTo = -1; + if (from >= 0 && to >= 0 && from !== to) list.moveTo(from, to); + } + } + } + + Image { + id: img + visible: row.icon.length > 0 + anchors.left: grip.right + anchors.verticalCenter: parent.verticalCenter + width: visible ? 18 * list.s : 0 + height: 18 * list.s + sourceSize: Qt.size(width * 2, height * 2) + source: row.icon + asynchronous: true + } + + Column { + anchors.left: img.right + anchors.leftMargin: (row.icon.length > 0 ? 10 : 2) * list.s + anchors.right: del.left + anchors.rightMargin: 8 * list.s + anchors.verticalCenter: parent.verticalCenter + Text { + width: parent.width + text: list.labelOf(row.modelData) + color: Theme.bright + font.family: Theme.font + font.pixelSize: 12 * list.s + elide: Text.ElideRight + } + Text { + visible: row.sub.length > 0 + width: parent.width + text: row.sub + color: Theme.faint + font.family: Theme.mono + font.pixelSize: 9.5 * list.s + elide: Text.ElideRight + } + } + + // remove + Rectangle { + id: del + anchors.right: parent.right + anchors.rightMargin: 5 * list.s + anchors.verticalCenter: parent.verticalCenter + width: 24 * list.s + height: 24 * list.s + radius: width / 2 + color: delArea.containsMouse ? Theme.sheen : "transparent" + border.width: delArea.containsMouse ? 1 : 0 + border.color: Theme.verm + GlyphIcon { + anchors.centerIn: parent + width: 11 * list.s + height: 11 * list.s + name: "close" + color: Theme.verm + } + MouseArea { + id: delArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: list.remove(row.index) + } + } + } + } +} diff --git a/plugins/launchpad/content/Panel.qml b/plugins/launchpad/content/Panel.qml new file mode 100644 index 0000000..c85f5d3 --- /dev/null +++ b/plugins/launchpad/content/Panel.qml @@ -0,0 +1,611 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Ryoku.PluginKit +import Ryoku.PluginKit.Singletons + +// Panel: desktop widgets, pinned apps, and quick commands. EDIT reveals +// unpin / remove marks, the add rows, the widget picker, and each section's HIDE / SHOW. +Item { + id: root + + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 380 + property bool active: false + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + function setting(key, def) { return root.service ? root.service.get(key, def) : def; } + readonly property int cols: Math.max(3, Math.min(8, root.setting("columns", 5))) + + // section drag in flight: which section (by order), where it lands, its height + property int secFrom: -1 + property int secTo: -1 + property real secDragH: 0 + // landing index for the section at `from` dragged by `dy`: the sections whose + // middles end up above its middle + function secTarget(from, dy) { + const it = secRep.itemAt(from); + const mid = it.y + it.height / 2 + dy; + let t = 0; + for (let i = 0; i < secRep.count; i++) { + const o = secRep.itemAt(i); + if (i !== from && o.visible && o.y + o.height / 2 < mid) t++; + } + return t; + } + property bool editing: false + property bool adding: false + + implicitWidth: root.widthBudget + implicitHeight: col.implicitHeight + 32 * root.s + + onActiveChanged: if (!active) { editing = false; adding = false; } + + // a section: hairline, eyebrow, body. In EDIT every section shows its header with + // a HIDE / SHOW pill (its show* setting); a hidden section keeps only that header. + component Section: Column { + id: sec + property string label + property string key // its show* setting + property string sid // its id in the section order + readonly property int pos: root.service ? root.service.sectionOrder.indexOf(sid) : 0 + readonly property bool shown: root.setting(key, true) + readonly property bool dragging: secDrag.active + // how far the section's wrapper is moved: with the pointer while dragged, aside + // while another section passes it + readonly property real offset: dragging ? secDrag.translation.y + : root.secFrom < 0 ? 0 + : (pos > root.secFrom && pos <= root.secTo) ? -(root.secDragH + col.spacing) + : (pos < root.secFrom && pos >= root.secTo) ? (root.secDragH + col.spacing) : 0 + default property alias body: secBody.data + width: col.width + spacing: 12 * root.s + visible: root.editing || shown + + Rectangle { width: sec.width; height: 1; color: Theme.hair } + Item { + width: sec.width + height: Math.max(secLabel.implicitHeight, secPill.height) + // grip: drag the section + Item { + id: secGrip + visible: root.editing + width: visible ? 24 * root.s : 0 + height: 20 * root.s + anchors.verticalCenter: parent.verticalCenter + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2.5 * root.s + Repeater { + model: 3 + Rectangle { + width: 12 * root.s + height: 1.5 * root.s + radius: height / 2 + color: sec.dragging || secGripHover.hovered ? Theme.accent : Theme.iconDim + } + } + } + HoverHandler { id: secGripHover; cursorShape: sec.dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor } + DragHandler { + id: secDrag + target: null + xAxis.enabled: false + onTranslationChanged: if (active) { + root.secFrom = sec.pos; + root.secDragH = sec.height; + root.secTo = root.secTarget(sec.pos, translation.y); + } + onActiveChanged: if (!active) { + const from = root.secFrom, to = root.secTo; + root.secFrom = -1; + root.secTo = -1; + if (from >= 0 && to >= 0 && from !== to) root.service.moveSection(from, to); + } + } + } + MicroLabel { + id: secLabel + anchors.left: secGrip.right + anchors.verticalCenter: parent.verticalCenter + label: sec.label + s: root.s + opacity: sec.shown ? 1 : 0.45 + } + Rectangle { + id: secPill + visible: root.editing + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: secPillText.implicitWidth + 14 * root.s + height: 20 * root.s + radius: height / 2 + color: secPillArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: sec.shown ? Theme.hair : Theme.accent + Text { + id: secPillText + anchors.centerIn: parent + text: sec.shown ? "HIDE" : "SHOW" + color: sec.shown ? Theme.faint : Theme.accent + font.family: Theme.mono + font.pixelSize: 9.5 * root.s + font.letterSpacing: 1.2 * root.s + } + MouseArea { + id: secPillArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.service.save(sec.key, !sec.shown) + } + } + } + Column { + id: secBody + visible: sec.shown + width: sec.width + spacing: 12 * root.s + } + } + + // a hairline chip: icon + label, accent on hover + component Chip: Rectangle { + id: chip + property string label + property string glyph: "" + signal clicked() + width: chipRow.implicitWidth + 22 * root.s + height: 30 * root.s + radius: Motion.rSmall * root.s + color: chipArea.pressed ? Theme.threadBg : chipArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: chipArea.containsMouse ? Theme.accent : Theme.hair + Behavior on color { ColorAnimation { duration: Motion.fast } } + Behavior on border.color { ColorAnimation { duration: Motion.fast } } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: chip.clicked() + } + Row { + id: chipRow + anchors.centerIn: parent + spacing: 7 * root.s + GlyphIcon { + visible: chip.glyph.length > 0 + anchors.verticalCenter: parent.verticalCenter + width: 12 * root.s + height: 12 * root.s + name: chip.glyph + color: chipArea.containsMouse ? Theme.accent : Theme.iconDim + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: chip.label + color: chipArea.containsMouse ? Theme.bright : Theme.dim + font.family: Theme.mono + font.pixelSize: 11.5 * root.s + } + } + } + + // a bordered single-line input + component Field: Rectangle { + id: fld + property alias text: inp.text + property alias input: inp + property string placeholder + signal accepted() + height: 30 * root.s + radius: Motion.rSmall * root.s + color: "transparent" + border.width: 1 + border.color: inp.activeFocus ? Theme.accent : Theme.border + TextInput { + id: inp + anchors.fill: parent + anchors.leftMargin: 10 * root.s + anchors.rightMargin: 10 * root.s + verticalAlignment: TextInput.AlignVCenter + clip: true + color: Theme.bright + selectionColor: Theme.accent + selectedTextColor: Theme.cardBot + font.family: Theme.mono + font.pixelSize: 11.5 * root.s + onAccepted: fld.accepted() + Text { + visible: !inp.text && !inp.activeFocus + anchors.verticalCenter: parent.verticalCenter + text: fld.placeholder + color: Theme.faint + font: inp.font + } + } + } + + Column { + id: col + x: 16 * root.s + y: 16 * root.s + width: root.width - 32 * root.s + spacing: 14 * root.s + + // header: title, EDIT toggle + Item { + width: col.width + height: Math.max(head.implicitHeight, editPill.height) + + Text { + id: head + anchors.verticalCenter: parent.verticalCenter + text: "Launchpad" + color: Theme.bright + font.family: Theme.display + font.pixelSize: 20 * root.s + } + + Rectangle { + id: editPill + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: editText.implicitWidth + 18 * root.s + height: editText.implicitHeight + 9 * root.s + radius: height / 2 + color: root.editing ? Theme.threadBg : editArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: root.editing ? Theme.accent : Theme.border + Text { + id: editText + anchors.centerIn: parent + text: root.editing ? "DONE" : "EDIT" + color: root.editing ? Theme.accent : Theme.dim + font.family: Theme.mono + font.pixelSize: 10.5 * root.s + font.letterSpacing: 1.2 * root.s + } + MouseArea { + id: editArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { root.editing = !root.editing; root.adding = false; } + } + } + } + + // the sections, in your order (EDIT moves them) + Repeater { + id: secRep + model: root.service ? root.service.sectionOrder : [] + delegate: Item { + id: secSlot + required property string modelData + readonly property var sec: secLoader.item + width: col.width + height: secLoader.height + visible: root.editing || root.setting(({ widgets: "showWidgets", pinned: "showPinned", commands: "showCommands" })[modelData], true) + z: sec && sec.dragging ? 10 : 0 + transform: Translate { + y: secSlot.sec ? secSlot.sec.offset : 0 + Behavior on y { + enabled: !(secSlot.sec && secSlot.sec.dragging) + NumberAnimation { duration: Motion.fast; easing.type: Motion.easeStandard } + } + } + // the lifted card under a dragged section + Rectangle { + visible: !!secSlot.sec && secSlot.sec.dragging + x: -8 * root.s + y: -4 * root.s + width: parent.width + 16 * root.s + height: parent.height + 12 * root.s + radius: Motion.rSmall * root.s + color: Theme.cardBot + border.width: 1 + border.color: Theme.accent + } + Loader { + id: secLoader + width: col.width + sourceComponent: ({ widgets: widgetsSec, pinned: pinnedSec, commands: commandsSec })[secSlot.modelData] + } + } + } + } + + // desktop widgets + Component { + id: widgetsSec + Section { + label: "Widgets" + key: "showWidgets" + sid: "widgets" + DeskWidgets { + width: col.width + s: root.s + active: root.active + editing: root.editing + service: root.service + } + } + } + + // pinned apps + Component { + id: pinnedSec + Section { + label: "Pinned" + key: "showPinned" + sid: "pinned" + Grid { + id: grid + visible: !root.editing + width: col.width + columns: root.cols + readonly property real cell: width / root.cols + + Repeater { + model: root.service ? root.service.pinned : [] + delegate: Item { + id: tile + required property var modelData + width: grid.cell + height: 66 * root.s + + Rectangle { + anchors.fill: parent + anchors.margins: 2 * root.s + radius: Motion.rSmall * root.s + color: tileArea.containsMouse ? Theme.sheen : "transparent" + border.width: 1 + border.color: tileArea.containsMouse ? Theme.accent : "transparent" + Behavior on color { ColorAnimation { duration: Motion.fast } } + } + Image { + id: appIcon + anchors.horizontalCenter: parent.horizontalCenter + y: 9 * root.s + width: 30 * root.s + height: 30 * root.s + sourceSize: Qt.size(width * 2, height * 2) + source: root.service ? root.service.icon(tile.modelData) : "" + asynchronous: true + } + // fallback monogram when the theme has no icon + Text { + visible: appIcon.status !== Image.Ready + anchors.centerIn: appIcon + text: tile.modelData.name.charAt(0).toUpperCase() + color: Theme.accent + font.family: Theme.display + font.pixelSize: 20 * root.s + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: appIcon.bottom + anchors.topMargin: 6 * root.s + width: parent.width - 8 * root.s + horizontalAlignment: Text.AlignHCenter + text: tile.modelData.name + color: tileArea.containsMouse ? Theme.bright : Theme.dim + font.family: Theme.font + font.pixelSize: 10 * root.s + elide: Text.ElideRight + } + MouseArea { + id: tileArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.service.launch(tile.modelData) + } + } + } + + // add tile + Item { + visible: root.adding || (root.service && root.service.pinned.length === 0) + width: grid.cell + height: 66 * root.s + Rectangle { + anchors.fill: parent + anchors.margins: 2 * root.s + radius: Motion.rSmall * root.s + color: "transparent" + border.width: 1 + border.color: addArea.containsMouse || root.adding ? Theme.accent : Theme.border + } + Text { + anchors.centerIn: parent + text: root.adding ? "−" : "+" + color: addArea.containsMouse || root.adding ? Theme.accent : Theme.faint + font.family: Theme.display + font.pixelSize: 22 * root.s + } + MouseArea { + id: addArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.adding = !root.adding; + if (root.adding) Qt.callLater(() => search.input.forceActiveFocus()); + } + } + } + } + + // EDIT: pinned apps in order, then a button that opens the picker + Column { + visible: root.editing + width: col.width + spacing: 6 * root.s + OrderList { + width: col.width + s: root.s + model: root.editing && root.service ? root.service.pinned : [] + labelOf: (e) => e.name + iconOf: (e) => root.service.icon(e) + onMoveTo: (from, to) => root.service.moveApp(from, to) + onRemove: (i) => root.service.togglePin(root.service.pinned[i]) + } + Chip { + label: root.adding ? "Done adding" : "Pin an app" + glyph: root.adding ? "check" : "install" + onClicked: { + root.adding = !root.adding; + if (root.adding) Qt.callLater(() => search.input.forceActiveFocus()); + } + } + } + + // app picker + Column { + visible: root.adding + width: col.width + spacing: 6 * root.s + + SearchField { + id: search + width: col.width + s: root.s + kanji: "探" + placeholder: "Find an app to pin" + onDismissed: root.adding = false + } + Rectangle { width: col.width; height: 1; color: Theme.hair } + + Repeater { + model: { + if (!root.adding || !root.service) return []; + const q = search.text.toLowerCase(); + return root.service.allApps.filter(e => !q || e.name.toLowerCase().includes(q) + || String(e.genericName || "").toLowerCase().includes(q)).slice(0, 6); + } + delegate: Rectangle { + id: pick + required property var modelData + readonly property bool on: root.service ? root.service.isPinned(modelData) : false + width: col.width + height: 34 * root.s + radius: Motion.rSmall * root.s + color: pickArea.containsMouse ? Theme.sheen : "transparent" + Image { + id: pickIcon + x: 8 * root.s + anchors.verticalCenter: parent.verticalCenter + width: 20 * root.s + height: 20 * root.s + sourceSize: Qt.size(width * 2, height * 2) + source: root.service ? root.service.icon(pick.modelData) : "" + asynchronous: true + } + Text { + anchors.left: pickIcon.right + anchors.leftMargin: 10 * root.s + anchors.right: pickMark.left + anchors.verticalCenter: parent.verticalCenter + text: pick.modelData.name + color: pick.on ? Theme.bright : Theme.dim + font.family: Theme.font + font.pixelSize: 12 * root.s + elide: Text.ElideRight + } + GlyphIcon { + id: pickMark + anchors.right: parent.right + anchors.rightMargin: 10 * root.s + anchors.verticalCenter: parent.verticalCenter + width: 13 * root.s + height: 13 * root.s + name: pick.on ? "check" : "install" + color: pick.on ? Theme.accent : pickArea.containsMouse ? Theme.bright : Theme.iconDim + } + MouseArea { + id: pickArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.service.togglePin(pick.modelData) + } + } + } + } + } + } + + // quick commands + Component { + id: commandsSec + Section { + label: "Commands" + key: "showCommands" + sid: "commands" + Flow { + visible: !root.editing + width: col.width + spacing: 6 * root.s + Repeater { + model: root.service ? root.service.commands : [] + delegate: Chip { + required property var modelData + required property int index + label: modelData.label + glyph: "send" + onClicked: root.service.run(modelData) + } + } + Text { + visible: root.service && root.service.commands.length === 0 + height: 30 * root.s + verticalAlignment: Text.AlignVCenter + text: "No commands yet. EDIT to add one." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 11.5 * root.s + } + } + + // EDIT: commands in order + OrderList { + visible: root.editing + width: col.width + s: root.s + model: root.editing && root.service ? root.service.commands : [] + labelOf: (c) => c.label + subOf: (c) => c.cmd + onMoveTo: (from, to) => root.service.moveCommand(from, to) + onRemove: (i) => root.service.removeCommand(i) + } + + // add a command + Row { + visible: root.editing + width: col.width + spacing: 6 * root.s + Field { id: cmdLabel; width: 92 * root.s; placeholder: "label"; onAccepted: cmdLine.input.forceActiveFocus() } + Field { id: cmdLine; width: col.width - 92 * root.s - 36 * root.s - 12 * root.s; placeholder: "command"; onAccepted: addCmd.add() } + Rectangle { + id: addCmd + function add() { + root.service.addCommand(cmdLabel.text, cmdLine.text); + cmdLabel.text = ""; + cmdLine.text = ""; + } + width: 36 * root.s + height: 30 * root.s + radius: Motion.rSmall * root.s + color: addCmdArea.pressed ? Theme.vermDeep : addCmdArea.containsMouse ? Theme.vermLit : Theme.accent + Text { anchors.centerIn: parent; text: "+"; color: Theme.cardBot; font.family: Theme.display; font.pixelSize: 18 * root.s } + MouseArea { id: addCmdArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: addCmd.add() } + } + } + } + } +} diff --git a/plugins/launchpad/content/Widget.qml b/plugins/launchpad/content/Widget.qml new file mode 100644 index 0000000..186a05b --- /dev/null +++ b/plugins/launchpad/content/Widget.qml @@ -0,0 +1,45 @@ +import QtQuick +import Ryoku.PluginKit.Singletons + +// Bar glyph: a 2x2 tile mark; the accent tile lights while the panel is open. +// A click only toggles the panel. +Item { + id: root + + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + + readonly property bool open: pluginApi ? pluginApi.panelOpen : false + + implicitWidth: 16 * root.s + implicitHeight: 18 * root.s + + Grid { + anchors.centerIn: parent + columns: 2 + spacing: 2.5 * root.s + Repeater { + model: 4 + Rectangle { + required property int index + width: 6 * root.s + height: 6 * root.s + radius: 1.5 * root.s + color: index === 0 ? Theme.accent : (root.open || area.containsMouse ? Theme.bright : Theme.iconDim) + Behavior on color { ColorAnimation { duration: Motion.fast } } + } + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: if (root.pluginApi) root.pluginApi.togglePanel() + } +} diff --git a/plugins/launchpad/manifest.json b/plugins/launchpad/manifest.json new file mode 100644 index 0000000..0572f6d --- /dev/null +++ b/plugins/launchpad/manifest.json @@ -0,0 +1,111 @@ +{ + "id": "launchpad", + "name": "Launchpad", + "version": "0.1.0", + "author": "guyb ", + "description": "A pinboard under one bar mark: your pick of widgets (clock, calendar, music, all-in-one, system stats, weather, notes, visualizer, or any installed desktop-widget plugin), favourite apps and one-click commands. Reorder or hide anything.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 380 + }, + "files": [ + "content/DeskWidgets.qml", + "content/OrderList.qml", + "assets/preview-widget.png", + "assets/preview-panel.png", + "assets/preview-edit.png" + ], + "capabilities": { + "densities": [ + "glyph", + "compact" + ] + }, + "defaults": { + "host": "topbarGlyph", + "label": "Launchpad" + }, + "dependencies": { + "commands": [ + "ryoku", + "ryoku-shell", + "ryoku-app", + "cava" + ] + }, + "metadata": { + "settings": [ + { + "key": "showWidgets", + "type": "toggle", + "label": "Show Widgets", + "group": "Sections", + "default": true + }, + { + "key": "showPinned", + "type": "toggle", + "label": "Show Pinned apps", + "group": "Sections", + "default": true + }, + { + "key": "showCommands", + "type": "toggle", + "label": "Show Commands", + "group": "Sections", + "default": true + }, + { + "key": "sectionOrder", + "type": "text", + "label": "Section order (or drag the sections in the panel's EDIT)", + "group": "Sections", + "default": "widgets pinned commands", + "placeholder": "pinned widgets commands" + }, + { + "key": "widgetList", + "type": "text", + "label": "Widgets (keys, space-separated; or pick them in the panel's EDIT)", + "group": "Widgets", + "default": "calendar music notes", + "placeholder": "clock calendar music aio stats weather notes visualizer plugin:" + }, + { + "key": "apps", + "type": "text", + "label": "Pinned apps (desktop ids, space-separated)", + "group": "Pinned", + "default": "", + "placeholder": "firefox kitty org.gnome.Nautilus" + }, + { + "key": "columns", + "type": "int", + "label": "App columns", + "group": "Pinned", + "default": 5, + "min": 3, + "max": 8 + }, + { + "key": "commands", + "type": "text", + "label": "Quick commands (Label=command ; ...)", + "group": "Commands", + "default": "Lock=loginctl lock-session ; Suspend=systemctl suspend", + "placeholder": "Lock=loginctl lock-session ; Files=nautilus" + } + ] + } +} diff --git a/plugins/launchpad/product-manifest.json b/plugins/launchpad/product-manifest.json new file mode 100644 index 0000000..bb8ba70 --- /dev/null +++ b/plugins/launchpad/product-manifest.json @@ -0,0 +1,121 @@ +{ + "schema": 1, + "id": "launchpad", + "category": "plugins", + "version": "0.1.0", + "destination": "ryoku/plugins/launchpad", + "files": [ + { + "source": ".gitignore", + "destination": ".gitignore", + "sha256": "379dfa9273952c08fa44042de676ed4addc26a7c4d4c90235d6ff2c73dffb810", + "mode": "0644", + "size": 22, + "install": true + }, + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "1ed2303778c198c25ba780642175eba1cac2f191ec0b3fe464847dac017cc54f", + "mode": "0644", + "size": 3958, + "install": true + }, + { + "source": "CLAUDE.md", + "destination": "CLAUDE.md", + "sha256": "09ffb8ebdb8adf50bf4bde26dfe2109b4af3a4a8cc3f30cc536be42bb912c033", + "mode": "0644", + "size": 152, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "06181c521ca2a124453c1db837e83a0c076ccb465b6df225d8ad2376c5bfb20f", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "d5603e28f235be4f99a7c2851e67b3c87925586ba2e05d7d0329e211cf1cfe5a", + "mode": "0644", + "size": 4030, + "install": false + }, + { + "source": "assets/preview-edit.png", + "destination": "assets/preview-edit.png", + "sha256": "f5bfaa348c13d1438b5f0f58b9412beb0314ab8bf1eb520b387c6a578ef3a9c1", + "mode": "0644", + "size": 55331, + "install": false + }, + { + "source": "assets/preview-panel.png", + "destination": "assets/preview-panel.png", + "sha256": "32387ed8cf6fbd941ee06c998bd20456342d1eaca4169576000ced47879139d2", + "mode": "0644", + "size": 45726, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "696ba6fce98962e2bb20d57fa31e4aa704baf9e8997808c215cd4ae4ef4e5661", + "mode": "0644", + "size": 15883, + "install": false + }, + { + "source": "content/DeskWidgets.qml", + "destination": "content/DeskWidgets.qml", + "sha256": "f681f24300f1b4b80d055b15a02b25861bed486532690aa89d28404cf1dca215", + "mode": "0644", + "size": 25327, + "install": true + }, + { + "source": "content/OrderList.qml", + "destination": "content/OrderList.qml", + "sha256": "1d92e1a46df06e337f478ec5430c63f9cb7157be1cc4df31430dd00ffd055bae", + "mode": "0644", + "size": 6100, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "400d82b7de3df92ecc9d1a9d46f5d502427ac39b245a2f29dc5c0b4f2004238f", + "mode": "0644", + "size": 25119, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "a61466c500a46003c20addcc12db423a307cef87a1b1ba152faa575269ee38f8", + "mode": "0644", + "size": 1242, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "88694053ad1a9eaa3d0ea312750ad776fc02b25bf5519f553142326582e81596", + "mode": "0644", + "size": 2887, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "a168692c516adcbe3728e71533ec16dba1ecce478cfce583c71a8cc811b14183", + "mode": "0644", + "size": 11737, + "install": true + } + ] +} diff --git a/plugins/launchpad/service/Main.qml b/plugins/launchpad/service/Main.qml new file mode 100644 index 0000000..ef71fc8 --- /dev/null +++ b/plugins/launchpad/service/Main.qml @@ -0,0 +1,262 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +// Pinned apps, quick commands and the chosen widgets (all live in the plugin's settings), +// the app and widget lists to pick from, notes, an icon index, and a CPU / memory sample. +Item { + id: svc + + property var pluginApi + readonly property var settings: pluginApi && pluginApi.pluginSettings ? pluginApi.pluginSettings : ({}) + + // A save takes a moment to round-trip through plugins.json. Until it lands, `pending` + // holds the new value so the panel shows it at once and a quick second edit builds on + // it instead of on the stale setting (which made reorders snap back). + property var pending: ({}) + function get(key, def) { + const v = svc.pending[key]; + return v !== undefined ? v : (svc.settings[key] ?? def); + } + function save(key, value) { + const p = Object.assign({}, svc.pending); + p[key] = value; + svc.pending = p; + pendingExpiry.restart(); + pluginApi.saveSetting(key, value); + } + onSettingsChanged: { + const p = {}; + for (const k in svc.pending) + if (JSON.stringify(svc.settings[k]) !== JSON.stringify(svc.pending[k])) p[k] = svc.pending[k]; + svc.pending = p; + } + // a save that never lands (tool failed) stops masking the real setting + Timer { id: pendingExpiry; interval: 5000; onTriggered: svc.pending = ({}) } + + // apps: space-separated desktop ids, e.g. "firefox kitty org.gnome.Nautilus" + readonly property var appIds: String(get("apps", "")).split(/\s+/).filter(x => x.length) + // allApps is read so this re-resolves once the desktop entries load (heuristicLookup + // alone is not reactive, so pins came up empty after a shell restart) + readonly property var pinned: allApps.length >= 0 ? appIds.map(id => DesktopEntries.heuristicLookup(id)).filter(e => !!e) : [] + readonly property var allApps: DesktopEntries.applications.values + .filter(e => !e.noDisplay) + .sort((a, b) => a.name.localeCompare(b.name)) + + // commands: "Label=command ; Label=command" + readonly property var commands: String(get("commands", "")).split(";") + .map(c => c.trim()).filter(c => c.includes("=")) + .map(c => ({ label: c.slice(0, c.indexOf("=")).trim(), cmd: c.slice(c.indexOf("=") + 1).trim() })) + + property var icons: ({}) + property real cpu: 0 // 0..1 + property real mem: 0 // 0..1 + property real swap: 0 // 0..1 + property string memText: "" + property var load: [0, 0, 0] + property real uptime: 0 // seconds + property var _lastCpu: null + + function icon(entry) { + const n = entry ? String(entry.icon || "") : ""; + if (n.startsWith("/")) return "file://" + n; + return svc.icons[n] ? "file://" + svc.icons[n] : ""; + } + function isPinned(entry) { return svc.appIds.includes(entry.id); } + function togglePin(entry) { + const ids = svc.isPinned(entry) ? svc.appIds.filter(x => x !== entry.id) : svc.appIds.concat([entry.id]); + svc.save("apps", ids.join(" ")); + } + // list with the item at `from` moved to `to`; null when nothing changes + function movedTo(list, from, to) { + if (from < 0 || to < 0 || from >= list.length || to >= list.length || from === to) return null; + const a = list.slice(); + a.splice(to, 0, a.splice(from, 1)[0]); + return a; + } + function moveApp(from, to) { + const a = svc.movedTo(svc.pinned.map(e => e.id), from, to); + if (a) svc.save("apps", a.join(" ")); + } + function moveCommand(from, to) { + const a = svc.movedTo(svc.commands, from, to); + if (a) svc.saveCommands(a); + } + + // sections: the panel order, "widgets pinned commands"; unknown keys drop, + // missing ones append, so a new section shows up for old settings + readonly property var allSections: ["widgets", "pinned", "commands"] + readonly property var sectionOrder: { + const o = String(get("sectionOrder", "")).split(/\s+/).filter(k => allSections.includes(k)); + return o.concat(allSections.filter(k => !o.includes(k))); + } + function moveSection(from, to) { + const a = svc.movedTo(svc.sectionOrder, from, to); + if (a) svc.save("sectionOrder", a.join(" ")); + } + + function launch(entry) { + entry.execute(); + pluginApi.closePanel(); + } + + function addCommand(label, cmd) { + label = label.replace(/[=;]/g, " ").trim(); + cmd = cmd.replace(/;/g, " ").trim(); + if (!label || !cmd) return; + svc.saveCommands(svc.commands.concat([{ label, cmd }])); + } + function removeCommand(i) { svc.saveCommands(svc.commands.filter((_, j) => j !== i)); } + function saveCommands(list) { + svc.save("commands", list.map(c => c.label + "=" + c.cmd).join(" ; ")); + } + // ponytail: argv split with simple quotes, no shell (no pipes / && / $VARS); wrap a + // script in bin/ or your PATH if you need those. + function argv(cmd) { + return (cmd.match(/"[^"]*"|'[^']*'|\S+/g) || []).map(a => a.replace(/^(["'])(.*)\1$/, "$2")); + } + function run(c) { + Quickshell.execDetached(["ryoku-app", "terminal", "--"].concat(svc.argv(c.cmd))); + pluginApi.closePanel(); + } + // installed desktop-widget plugins, hosted in the panel's Widgets section + property var deskPlugins: [] + + // widgets: the chosen widget keys in panel order, e.g. "calendar notes plugin:awe-git-dashboard" + readonly property var availableWidgets: [ + { key: "clock", label: "Clock" }, + { key: "calendar", label: "Calendar" }, + { key: "music", label: "Music" }, + { key: "aio", label: "All-in-one" }, + { key: "stats", label: "System stats" }, + { key: "weather", label: "Weather" }, + { key: "notes", label: "Notes" }, + { key: "visualizer", label: "Visualizer" } + ].concat(deskPlugins.map(p => ({ key: "plugin:" + p.id, label: p.name, plugin: p }))) + readonly property var widgetKeys: String(get("widgetList", "calendar music notes")).split(/\s+/).filter(x => x.length) + readonly property var chosenWidgets: widgetKeys.map(k => availableWidgets.find(w => w.key === k)).filter(w => !!w) + function moveWidget(from, to) { + const a = svc.movedTo(svc.chosenWidgets.map(w => w.key), from, to); + if (a) svc.save("widgetList", a.join(" ")); + } + function toggleWidget(key) { + const ks = widgetKeys.includes(key) ? widgetKeys.filter(k => k !== key) : widgetKeys.concat([key]); + svc.save("widgetList", ks.join(" ")); + } + Process { + id: plugList + command: ["ryoku", "plugin", "list", "--json"] + running: true + stdout: StdioCollector { + onStreamFinished: { + try { + svc.deskPlugins = JSON.parse(this.text) + .filter(p => (p.hosts || []).includes("desktopWidget") && p.id !== "launchpad"); + } catch (e) {} + } + } + } + + // notes: one plain-text file in stateDir, saved shortly after typing stops + property string notes: notesFile.loaded ? notesFile.text() : "" + FileView { + id: notesFile + path: svc.pluginApi ? svc.pluginApi.stateDir + "/notes.txt" : "" + printErrors: false + property bool loaded: false + onLoaded: loaded = true + onLoadFailed: loaded = true // first run: no file yet + } + Timer { id: notesSave; interval: 600; onTriggered: notesFile.setText(svc.notes) } + function setNotes(t) { if (t === svc.notes) return; svc.notes = t; notesSave.restart(); } + + // re-list desktop-widget plugins each time the panel opens + readonly property bool panelOpen: pluginApi ? pluginApi.panelOpen : false + onPanelOpenChanged: if (panelOpen && !plugList.running) plugList.running = true + + Process { + command: ["ryoku-shell", "icons"] + running: true + stdout: StdioCollector { + onStreamFinished: { + try { svc.icons = JSON.parse(this.text).icons || {}; } catch (e) {} + } + } + } + + FileView { id: stat; path: "/proc/stat"; blockAllReads: true; printErrors: false } + FileView { id: loadavg; path: "/proc/loadavg"; blockAllReads: true; printErrors: false } + FileView { id: uptimeFile; path: "/proc/uptime"; blockAllReads: true; printErrors: false } + FileView { id: meminfo; path: "/proc/meminfo"; blockAllReads: true; printErrors: false } + + // sampled only while the panel is open + Timer { + interval: 2000 + repeat: true + triggeredOnStart: true + running: svc.pluginApi ? svc.pluginApi.panelOpen : false + onTriggered: { + stat.reload(); + meminfo.reload(); + const f = (stat.text().split("\n")[0] || "").trim().split(/\s+/).slice(1).map(Number); + if (f.length >= 4) { + const idle = f[3] + (f[4] || 0), total = f.reduce((a, b) => a + b, 0); + if (svc._lastCpu && total > svc._lastCpu.total) + svc.cpu = 1 - (idle - svc._lastCpu.idle) / (total - svc._lastCpu.total); + svc._lastCpu = { idle, total }; + } + const m = meminfo.text(); + const kb = k => Number((m.match(new RegExp("^" + k + ":\\s+(\\d+)", "m")) || [0, 0])[1]); + if (kb("MemTotal") > 0) { + svc.mem = 1 - kb("MemAvailable") / kb("MemTotal"); + svc.memText = ((kb("MemTotal") - kb("MemAvailable")) / 1048576).toFixed(1) + " / " + (kb("MemTotal") / 1048576).toFixed(1) + " GiB"; + } + svc.swap = kb("SwapTotal") > 0 ? 1 - kb("SwapFree") / kb("SwapTotal") : 0; + loadavg.reload(); + svc.load = loadavg.text().trim().split(/\s+/).slice(0, 3).map(Number); + uptimeFile.reload(); + svc.uptime = Number(uptimeFile.text().split(" ")[0]) || 0; + } + } + + // weather: the shell daemon's public `weather` feed (it owns the fetch, location and + // units, set in Ryoku Hub), so this plugin makes no network call of its own + property var weather: null + Socket { + id: wx + path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/ryoku-shell.sock" + parser: SplitParser { + onRead: line => { + try { const f = JSON.parse(line); if (f.status) svc.weather = f; } catch (e) {} + } + } + onConnectedChanged: if (connected) { write("subscribe weather\n"); flush(); } + } + Timer { + interval: 5000 + running: svc.panelOpen && !wx.connected + repeat: true + triggeredOnStart: true + onTriggered: wx.connected = true + } + + // visualizer: a cava spectrum, run only while a panel widget asks for it + property int vizUsers: 0 + property var bars: [] + readonly property string cavaConf: pluginApi ? pluginApi.stateDir + "/cava.conf" : "" + FileView { id: cavaFile; path: svc.cavaConf; printErrors: false } + Process { + id: cava + command: ["cava", "-p", svc.cavaConf] + running: svc.vizUsers > 0 && svc.panelOpen && svc.cavaConf.length > 0 + onRunningChanged: if (!running) svc.bars = [] + stdout: SplitParser { + onRead: line => svc.bars = line.split(";").filter(x => x.length).map(x => Number(x) / 100) + } + } + onCavaConfChanged: if (cavaConf) Qt.callLater(() => cavaFile.setText("[general]\nframerate = 30\nbars = 32\n\n[input]\nmethod = pipewire\nsource = auto\n\n" + + "[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\nmono_option = average\n\n" + + "[smoothing]\nnoise_reduction = 45\n")) +} diff --git a/plugins/registry.json b/plugins/registry.json index 6e538e9..4a0dc5d 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1411,6 +1411,36 @@ "topbarGlyph" ], "lastUpdated": "2026-10-06" + }, + { + "id": "launchpad", + "name": "Launchpad", + "version": "0.1.0", + "path": "plugins/launchpad", + "author": "guyb ", + "summary": "A pinboard under one bar mark: your pick of widgets (clock, calendar, music, all-in-one, system stats, weather, notes, visualizer, or any...", + "description": "A pinboard under one bar mark: your pick of widgets (clock, calendar, music, all-in-one, system stats, weather, notes, visualizer, or any installed desktop-widget plugin), favourite apps and one-click commands. Reorder or hide anything.", + "tags": [ + "bar-widget" + ], + "accent": "#8f8378", + "surface": "#101010", + "preview": "assets/preview-panel.png", + "screenshots": [ + "assets/preview-panel.png", + "assets/preview-edit.png", + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "86e25f0e399e09a7ec66630cef456378845207fd48d80ed7368f8b1df15245f6", + "official": false, + "tagline": "A pinboard under one bar mark: your pick of widgets (clock, calendar, music, all-in-one, system stats, weather, notes, visualizer, or any...", + "icon": "", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-10-06", + "upstream": "https://github.com/ryoku-dev/ryostore/tree/main/plugins/launchpad" } ] }