From 9b6b799a5d12e534f10cf38db7bb25bdb76effe1 Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 28 Aug 2026 20:58:37 +0000 Subject: [PATCH] [verified] feat(quickshell): add orbit launcher variant --- dotfiles/quickshell/shell.qml | 3 +- .../tests/OrbitLauncherHeadless.qml | 20 ++ .../widgets/launcher/OrbitLauncher.qml | 79 ++++ .../widgets/launcher/OrbitLauncherContent.qml | 338 ++++++++++++++++++ hosts/terra/home/hyprland.nix | 2 + 5 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 dotfiles/quickshell/tests/OrbitLauncherHeadless.qml create mode 100644 dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml create mode 100644 dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml diff --git a/dotfiles/quickshell/shell.qml b/dotfiles/quickshell/shell.qml index 278e5ff..d4bde52 100644 --- a/dotfiles/quickshell/shell.qml +++ b/dotfiles/quickshell/shell.qml @@ -17,7 +17,7 @@ Scope { SideBar {} BarBottom {} - // App launcher variants — SUPER CTRL 1–9; variant 8 remains the primary HUD. + // App launcher variants — 1–10; variant 8 remains the primary HUD. LauncherStack {} // 1 — left vertical list LauncherGrid {} // 2 — centered icon grid LauncherSpotlight {} // 3 — top-center command bar @@ -27,6 +27,7 @@ Scope { LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot) ApplicationLauncher {} // 8 — dense HUD command index (primary) BladeLauncher {} // 9 — asymmetric blade matrix + OrbitLauncher {} // 10 — radial targeting arena Notifications {} VolumeOsd {} diff --git a/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml b/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml new file mode 100644 index 0000000..007486f --- /dev/null +++ b/dotfiles/quickshell/tests/OrbitLauncherHeadless.qml @@ -0,0 +1,20 @@ +import QtQuick +import qs.widgets.launcher + +OrbitLauncherContent { + width: 1120 + height: 660 + query: "media" + selectedIndex: 3 + showIcons: false + apps: [ + { name: "Vivaldi", genericName: "Web Browser", comment: "Access network and web applications", icon: "vivaldi" }, + { name: "Obsidian", genericName: "Knowledge Base", comment: "Local-first markdown workspace", icon: "obsidian" }, + { name: "Blender", genericName: "3D Creation", comment: "Model, animate and render 3D scenes", icon: "blender" }, + { name: "Jellyfin Media Player", genericName: "Media Player", comment: "Stream media from the homelab library", icon: "jellyfin-media-player" }, + { name: "Spotify", genericName: "Music Player", comment: "Browse and play music", icon: "spotify" }, + { name: "Steam", genericName: "Game Platform", comment: "Launch and manage games", icon: "steam" }, + { name: "Cosmic Files", genericName: "File Manager", comment: "Browse and manage local storage", icon: "com.system76.CosmicFiles" }, + { name: "Alacritty", genericName: "Terminal Emulator", comment: "GPU accelerated command interface", icon: "utilities-terminal" } + ] +} diff --git a/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml b/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml new file mode 100644 index 0000000..9a5d833 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/OrbitLauncher.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import QtQuick +import qs.widgets.theme + +// Variant 10 — radial Orbit application launcher. +Scope { + id: root + + property bool active: false + function toggle() { root.active = !root.active; } + + GlobalShortcut { + name: "launcher10" + description: "Toggle app launcher (Orbit targeting)" + onPressed: root.toggle() + } + + PanelWindow { + id: win + visible: root.active + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + color: Theme.textAlpha(0) + anchors { top: true; left: true; right: true; bottom: true } + + property int selectedIndex: 0 + + function clampSelection(index) { + return model.apps.length === 0 ? 0 : Math.max(0, Math.min(model.apps.length - 1, index)); + } + function move(delta) { + const count = model.apps.length; + if (count === 0) + return; + selectedIndex = ((selectedIndex + delta) % count + count) % count; + } + function launch(index) { + if (model.launch(index)) + root.active = false; + } + + onVisibleChanged: { + if (visible) { + content.clearSearch(); + selectedIndex = 0; + content.focusSearch(); + } + } + + AppModel { id: model; search: content.query } + + Rectangle { + anchors.fill: parent + color: Theme.surface + opacity: 0.80 + MouseArea { anchors.fill: parent; onClicked: root.active = false } + } + + OrbitLauncherContent { + id: content + anchors.centerIn: parent + width: 1120 + height: 660 + scale: Math.min(1, (parent.width - 56) / width, (parent.height - 56) / height) + transformOrigin: Item.Center + apps: model.apps + selectedIndex: win.selectedIndex + onSelectionRequested: index => win.selectedIndex = win.clampSelection(index) + onMoveRequested: delta => win.move(delta) + onLaunchRequested: index => win.launch(index) + onDismissRequested: root.active = false + } + } +} diff --git a/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml b/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml new file mode 100644 index 0000000..496ff19 --- /dev/null +++ b/dotfiles/quickshell/widgets/launcher/OrbitLauncherContent.qml @@ -0,0 +1,338 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import qs.widgets.theme + +// Variant 10 visual core: radial application targeting arena plus execution +// tape. Shares Theme/AppModel contracts but no launcher panel primitives. +Item { + id: root + + property var apps: [] + property int selectedIndex: 0 + property bool showIcons: true + property alias query: orbitInput.text + + readonly property var selectedApp: apps.length > 0 && selectedIndex >= 0 && selectedIndex < apps.length + ? apps[selectedIndex] : null + readonly property int orbitCount: Math.min(8, apps.length) + + signal selectionRequested(int index) + signal moveRequested(int delta) + signal launchRequested(int index) + signal dismissRequested + + function focusSearch() { orbitInput.forceActiveFocus(); } + function clearSearch() { orbitInput.text = ""; } + + implicitWidth: 1120 + implicitHeight: 660 + + component MicroText: Text { + color: Theme.muted + font.family: Theme.microFont + font.pixelSize: 7 + font.letterSpacing: 0.9 + elide: Text.ElideRight + } + + Rectangle { anchors.fill: parent; color: Theme.surface } + + // Perimeter bracket frame, deliberately open on all four sides. + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: 24; startY: 92 + PathLine { x: 24; y: 24 } + PathLine { x: 128; y: 24 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: root.width - 128; startY: 24 + PathLine { x: root.width - 24; y: 24 } + PathLine { x: root.width - 24; y: 92 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: 24; startY: root.height - 92 + PathLine { x: 24; y: root.height - 24 } + PathLine { x: 128; y: root.height - 24 } + } + ShapePath { + fillColor: Theme.textAlpha(0) + strokeColor: Theme.accent + strokeWidth: 2 + startX: root.width - 128; startY: root.height - 24 + PathLine { x: root.width - 24; y: root.height - 24 } + PathLine { x: root.width - 24; y: root.height - 92 } + } + } + + Text { + x: 44; y: 38 + text: "ORBIT // APPLICATION TARGETING" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 17 + font.bold: true + font.letterSpacing: 1.5 + } + MicroText { x: 46; y: 64; text: "10 / RADIAL INDEX / EXECUTION CONTROL"; color: Theme.accent } + MicroText { anchors.right: parent.right; anchors.rightMargin: 44; y: 48; text: root.apps.length + " TRACKED OBJECTS" } + + // Radial targeting arena. + Item { + id: arena + x: 36; y: 92 + width: 716; height: 500 + + // Axis traces. + Rectangle { anchors.centerIn: parent; width: parent.width - 28; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: parent.height - 16; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 414; height: 414; radius: 207; color: Theme.textAlpha(0.008); border.width: 1; border.color: Theme.accentAlpha(0.50) } + Rectangle { anchors.centerIn: parent; width: 326; height: 326; radius: 163; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 220; height: 220; radius: 110; color: Theme.textAlpha(0); border.width: 1; border.color: Theme.accentAlpha(0.28) } + + // Cardinal labels. + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 8; text: "N // INDEX 00" } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; anchors.bottom: parent.bottom; anchors.bottomMargin: 5; text: "S // COMMAND BUS" } + MicroText { anchors.verticalCenter: parent.verticalCenter; x: 8; text: "W"; color: Theme.accent } + MicroText { anchors.verticalCenter: parent.verticalCenter; anchors.right: parent.right; anchors.rightMargin: 8; text: "E"; color: Theme.accent } + + // App nodes distributed around the outer orbit. + Repeater { + model: root.orbitCount + + MouseArea { + id: node + required property int index + readonly property var app: root.apps[index] + readonly property real angle: -Math.PI / 2 + index * (2 * Math.PI / Math.max(1, root.orbitCount)) + readonly property bool selected: index === root.selectedIndex + width: selected ? 92 : 74 + height: selected ? 52 : 44 + x: arena.width / 2 + Math.cos(angle) * 205 - width / 2 + y: arena.height / 2 + Math.sin(angle) * 205 - height / 2 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Shape { + id: nodeShape + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: node.selected ? Theme.selection : Theme.surface + strokeColor: node.selected ? Theme.accent : Theme.hair + strokeWidth: node.selected ? 2 : 1 + startX: 10; startY: 0 + PathLine { x: nodeShape.width; y: 0 } + PathLine { x: nodeShape.width - 10; y: nodeShape.height } + PathLine { x: 0; y: nodeShape.height } + PathLine { x: 10; y: 0 } + } + } + + Rectangle { + x: 7; anchors.verticalCenter: parent.verticalCenter + width: 27; height: 27 + color: node.selected ? Theme.accentAlpha(0.18) : Theme.textAlpha(0.025) + border.width: 1 + border.color: node.selected ? Theme.accent : Theme.hair + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: 20; source: root.showIcons ? Quickshell.iconPath(node.app.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: String(node.app.name || "?").charAt(0).toUpperCase(); color: node.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 11; font.bold: true } + } + Text { + x: 40; anchors.verticalCenter: parent.verticalCenter + width: parent.width - 48 + text: node.app.name || "UNKNOWN" + color: node.selected ? Theme.accent : Theme.text + font.family: Theme.displayFont + font.pixelSize: 8 + font.bold: node.selected + elide: Text.ElideRight + } + } + } + + // Core target, not a conventional card. + Item { + id: core + anchors.centerIn: parent + width: 196; height: 196 + + Rectangle { anchors.centerIn: parent; width: 186; height: 186; radius: 93; color: Theme.surface; border.width: 2; border.color: Theme.accent } + Rectangle { anchors.centerIn: parent; width: 156; height: 156; radius: 78; color: Theme.accentAlpha(0.04); border.width: 1; border.color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 118; height: 118; color: Theme.textAlpha(0.012); border.width: 1; border.color: Theme.accentAlpha(0.42); transform: Rotation { angle: 45 } } + Rectangle { anchors.centerIn: parent; width: 196; height: 1; color: Theme.hair } + Rectangle { anchors.centerIn: parent; width: 1; height: 196; color: Theme.hair } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: 31; width: 58; height: 58 + color: Theme.surface + border.width: 1; border.color: Theme.accent + IconImage { visible: root.showIcons; anchors.centerIn: parent; implicitSize: 42; source: root.showIcons && root.selectedApp ? Quickshell.iconPath(root.selectedApp.icon || "application-x-executable", "application-x-executable") : "" } + Text { visible: !root.showIcons; anchors.centerIn: parent; text: root.selectedApp ? String(root.selectedApp.name || "?").charAt(0).toUpperCase() : "?"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 26; font.bold: true } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + y: 100; width: 156 + text: root.selectedApp ? root.selectedApp.name : "NO TARGET" + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 11 + font.bold: true + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 124; width: 150; horizontalAlignment: Text.AlignHCenter; text: root.selectedApp ? (root.selectedApp.genericName || "APPLICATION") : "AWAITING LOCK"; color: Theme.accent } + MicroText { anchors.horizontalCenter: parent.horizontalCenter; y: 146; text: "LOCK // " + String(Math.max(0, root.selectedIndex + 1)).padStart(3, "0") } + } + + // Search rail crosses the bottom of the arena. + Shape { + id: queryRail + x: 98; anchors.bottom: parent.bottom; anchors.bottomMargin: 24 + width: 520; height: 48 + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.accentAlpha(0.10) + strokeColor: Theme.accent + strokeWidth: 1 + startX: 16; startY: 0 + PathLine { x: queryRail.width; y: 0 } + PathLine { x: queryRail.width - 16; y: queryRail.height } + PathLine { x: 0; y: queryRail.height } + PathLine { x: 16; y: 0 } + } + } + Text { x: 118; anchors.bottom: parent.bottom; anchors.bottomMargin: 33; text: "⌕"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 19; font.bold: true } + TextInput { + id: orbitInput + x: 152; anchors.bottom: parent.bottom; anchors.bottomMargin: 31 + width: 385; height: 32 + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + selectionColor: Theme.accent + selectedTextColor: Theme.surface + font.family: Theme.displayFont + font.pixelSize: 15 + font.letterSpacing: 1 + clip: true + onTextChanged: root.selectionRequested(0) + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Right: case Qt.Key_Down: case Qt.Key_Tab: root.moveRequested(1); event.accepted = true; break; + case Qt.Key_Left: case Qt.Key_Up: case Qt.Key_Backtab: root.moveRequested(-1); event.accepted = true; break; + case Qt.Key_PageDown: root.moveRequested(5); event.accepted = true; break; + case Qt.Key_PageUp: root.moveRequested(-5); event.accepted = true; break; + case Qt.Key_Return: case Qt.Key_Enter: root.launchRequested(root.selectedIndex); event.accepted = true; break; + case Qt.Key_Escape: root.dismissRequested(); event.accepted = true; break; + } + } + Text { anchors.fill: parent; verticalAlignment: Text.AlignVCenter; visible: orbitInput.text.length === 0; text: "SCAN APPLICATION INDEX"; color: Theme.muted; font: orbitInput.font } + } + } + + // Right execution tape. + Item { + id: tape + x: 778; y: 92 + width: 308; height: 500 + + Shape { + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + ShapePath { + fillColor: Theme.textAlpha(0.014) + strokeColor: Theme.hair + strokeWidth: 1 + startX: 0; startY: 0 + PathLine { x: tape.width - 28; y: 0 } + PathLine { x: tape.width; y: 28 } + PathLine { x: tape.width; y: tape.height } + PathLine { x: 28; y: tape.height } + PathLine { x: 0; y: tape.height - 28 } + PathLine { x: 0; y: 0 } + } + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + startX: 0; startY: 0 + PathLine { x: 82; y: 0 } + PathLine { x: 82; y: 4 } + PathLine { x: 0; y: 4 } + PathLine { x: 0; y: 0 } + } + } + + Text { x: 16; y: 15; text: "EXECUTION TAPE"; color: Theme.accent; font.family: Theme.displayFont; font.pixelSize: 10; font.bold: true; font.letterSpacing: 1.2 } + MicroText { anchors.right: parent.right; anchors.rightMargin: 17; y: 17; text: root.apps.length + " ROWS" } + Rectangle { x: 14; y: 40; width: parent.width - 28; height: 1; color: Theme.hair } + + ListView { + id: tapeList + x: 12; y: 51; width: parent.width - 24; height: 350 + model: root.apps + currentIndex: root.selectedIndex + spacing: 3 + clip: true + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + + delegate: MouseArea { + id: tapeRow + required property int index + required property var modelData + readonly property bool selected: index === root.selectedIndex + width: ListView.view.width + height: 37 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.selectionRequested(index) + onClicked: root.launchRequested(index) + + Rectangle { anchors.fill: parent; color: tapeRow.selected ? Theme.selection : Theme.textAlpha(0.018); border.width: 1; border.color: tapeRow.selected ? Theme.accent : Theme.textAlpha(0.09) } + Rectangle { x: 0; width: tapeRow.selected ? 6 : 2; height: parent.height; color: tapeRow.selected ? Theme.accent : Theme.hair } + Text { x: 13; anchors.verticalCenter: parent.verticalCenter; width: 24; text: String(tapeRow.index + 1).padStart(2, "0"); color: tapeRow.selected ? Theme.accent : Theme.muted; font.family: Theme.microFont; font.pixelSize: 7 } + Text { x: 43; anchors.verticalCenter: parent.verticalCenter; width: parent.width - 116; text: tapeRow.modelData.name || "UNKNOWN"; color: tapeRow.selected ? Theme.accent : Theme.text; font.family: Theme.displayFont; font.pixelSize: 9; font.bold: tapeRow.selected; elide: Text.ElideRight } + MicroText { anchors.right: parent.right; anchors.rightMargin: 10; anchors.verticalCenter: parent.verticalCenter; text: tapeRow.selected ? "LOCK" : "PASS"; color: tapeRow.selected ? Theme.accent : Theme.muted } + } + } + + MicroText { x: 16; y: 417; width: parent.width - 32; height: 28; wrapMode: Text.Wrap; maximumLineCount: 2; text: root.selectedApp ? (root.selectedApp.comment || "No target metadata supplied.") : "No target acquired." } + + MouseArea { + id: execute + x: 14; anchors.bottom: parent.bottom; anchors.bottomMargin: 15 + width: parent.width - 28; height: 42 + enabled: root.selectedApp !== null + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.launchRequested(root.selectedIndex) + Rectangle { anchors.fill: parent; color: execute.containsMouse ? Theme.accent : Theme.accentAlpha(0.16); border.width: 1; border.color: Theme.accent } + Text { anchors.centerIn: parent; text: "COMMIT ORBITAL LAUNCH"; color: execute.containsMouse ? Theme.surface : Theme.accent; font.family: Theme.displayFont; font.pixelSize: 9; font.bold: true; font.letterSpacing: 1.1 } + } + } + + Row { + x: 42; y: 622; spacing: 4 + Repeater { model: 28; Rectangle { required property int index; width: 11; height: 3; color: index % 4 === 0 ? Theme.accent : Theme.hair; transform: Rotation { angle: -30 } } } + } + MicroText { x: 390; y: 617; text: "←↑↓→ TRACK // ENTER COMMIT // ESC RELEASE"; color: Theme.accent } + MicroText { anchors.right: parent.right; anchors.rightMargin: 40; y: 617; text: "ORBIT-10 // CRC 9C31" } +} diff --git a/hosts/terra/home/hyprland.nix b/hosts/terra/home/hyprland.nix index e0042b4..5f4ee2d 100644 --- a/hosts/terra/home/hyprland.nix +++ b/hosts/terra/home/hyprland.nix @@ -200,6 +200,8 @@ in ] ++ (map (n: bind "SUPER + CTRL + ${toString n}" (dsp.global "quickshell:launcher${toString n}")) (lib.range 1 9)) ++ [ + # variant 10 (CTRL+0 is already the quickshell restart binding below) + (bind "SUPER + CTRL + SHIFT + 0" (dsp.global "quickshell:launcher10")) # restart quickshell (also starts it if not running) (bind "SUPER + CTRL + 0" (dsp.exec "qs kill; sleep 0.3; qs")) # toggle the Slant sidebar