quickshell: launcher rises from the bottom frame; sliding pills in session menu and launcher list

- launcher moved into the bar window as a 5th SDF surface: melts out of
  the bottom frame edge, height morphs live as results filter
- session menu selection is a gliding pill (battery-menu tech)
- launcher selection uses an animated ListView highlight
- HyprlandFocusGrab gives both panels click-outside dismissal
- standalone Launcher.qml window removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-12 19:45:03 +01:00
parent d2bcdad2fe
commit 641d5c7e63

View file

@ -69,6 +69,7 @@ in
vec4 panel; // cx, cy, hw, hh dropdown panel (hw <= 0: none)
vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none)
vec4 session; // cx, cy, hw, hh session menu (hw <= 0: none)
vec4 launcher; // cx, cy, hw, hh bottom launcher (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor;
vec2 res;
@ -101,6 +102,8 @@ in
d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK);
if (session.z > 0.5)
d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK);
if (launcher.z > 0.5)
d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK);
float fw = fwidth(d);
// 1 inside the union, 0 outside (antialiased)
@ -134,7 +137,6 @@ in
singleton Theme 1.0 Theme.qml
singleton Commands 1.0 Commands.qml
Bar 1.0 Bar.qml
Launcher 1.0 Launcher.qml
'';
};
@ -203,15 +205,11 @@ in
property var mainBar: null
signal notificationReceived()
Launcher {
id: launcher
}
// Bound in hyprland.nix: Super+R app launcher,
// Super+L session menu (in the bar window).
// Bound in hyprland.nix: Super+R launcher (bottom of the
// bar window), Super+L session menu (right edge).
IpcHandler {
target: "launcher"
function toggle(): void { launcher.toggle(); }
function toggle(): void { if (root.mainBar) root.mainBar.toggleLauncher(); }
function powermenu(): void { if (root.mainBar) root.mainBar.toggleSession(); }
}
@ -248,210 +246,6 @@ in
'';
};
# App launcher + power menu (replaces anyrun). Full-screen transparent
# overlay with exclusive keyboard focus while open; Esc / click-outside
# closes. Apps come from Quickshell's DesktopEntries service.
"quickshell/Launcher.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
PanelWindow {
id: root
visible: false
screen: Quickshell.screens[0]
WlrLayershell.namespace: "quickshell-launcher"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
function toggle() {
if (visible) { close(); return; }
search.text = "";
list.currentIndex = 0;
visible = true;
search.forceActiveFocus();
}
function close() {
visible = false;
}
function score(name, extra, q) {
let n = name.toLowerCase();
if (n.startsWith(q)) return 5;
if (n.includes(" " + q)) return 4;
if (n.includes(q)) return 3;
if (extra && extra.toLowerCase().includes(q)) return 2;
// Fuzzy: q as an in-order subsequence of n (vktop
// vesktop); fewer skipped characters scores higher.
let qi = 0, gaps = 0, last = -1;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) {
if (last >= 0) gaps += i - last - 1;
last = i;
qi++;
}
}
if (qi === q.length) return 1 / (1 + gaps);
return 0;
}
property var entries: {
let q = search.text.toLowerCase().trim();
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
if (q === "") {
apps.sort((a, b) => a.name.localeCompare(b.name));
return apps.slice(0, 8);
}
let scored = [];
for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
if (s > 0) scored.push({ app: apps[i], s: s });
}
scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name));
return scored.slice(0, 8).map(x => x.app);
}
function activate(item) {
if (!item) return;
item.execute();
close();
}
// Click outside the box closes
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: box
x: Math.round((parent.width - width) / 2)
y: Math.round(parent.height * 0.25)
width: 350
height: col.height + 16
radius: 8 // matches hyprland decoration.rounding
color: Theme.base00
border.width: Theme.borderWidth
border.color: Theme.base03
// Swallow clicks inside the box
MouseArea { anchors.fill: parent }
Column {
id: col
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 8
spacing: 6
Rectangle {
width: parent.width
height: 36
radius: 6
color: Theme.base01
TextInput {
id: search
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: TextInput.AlignVCenter
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
clip: true
onTextChanged: list.currentIndex = 0
Keys.onEscapePressed: root.close()
Keys.onUpPressed: list.currentIndex = Math.max(0, list.currentIndex - 1)
Keys.onDownPressed: list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1)
Keys.onReturnPressed: root.activate(root.entries[list.currentIndex])
Keys.onEnterPressed: root.activate(root.entries[list.currentIndex])
Keys.onTabPressed: list.currentIndex = (list.currentIndex + 1) % Math.max(1, root.entries.length)
}
Text {
anchors.fill: search
verticalAlignment: Text.AlignVCenter
visible: search.text === ""
text: "Search"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 13
}
}
ListView {
id: list
width: parent.width
height: contentHeight
interactive: false
model: root.entries
delegate: Rectangle {
required property var modelData
required property int index
width: list.width
height: 32
radius: 6
color: list.currentIndex === index ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 10
Image {
visible: source != ""
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
source: Quickshell.iconPath(modelData.icon, true)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
elide: Text.ElideRight
width: 270
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: list.currentIndex = index
onClicked: root.activate(modelData)
}
}
}
}
}
}
'';
};
"quickshell/Bar.qml" = {
onChange = qsRestart;
text = ''
@ -478,7 +272,7 @@ in
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
// Keyboard only while the session menu is open (arrow/Enter nav)
WlrLayershell.keyboardFocus: sessionMenu.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: sessionMenu.open || launcherPanel.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
anchors {
top: true
@ -510,6 +304,12 @@ in
width: sessionMenu.visible ? sessionMenu.width : 0
height: sessionMenu.visible ? sessionMenu.height : 0
}
Region {
x: launcherPanel.visible ? launcherPanel.x : 0
y: launcherPanel.visible ? launcherPanel.y : 0
width: launcherPanel.visible ? launcherPanel.width : 0
height: launcherPanel.visible ? launcherPanel.height : 0
}
}
Item {
@ -531,6 +331,10 @@ in
sessionMenu.toggle();
}
function toggleLauncher() {
launcherPanel.toggle();
}
// Shell chrome: bar, frame, panel and toast rendered as
// ONE signed-distance field (caelestia-style). Surfaces merge
// via circular smooth-min, and the 2px border is the distance
@ -559,6 +363,11 @@ in
? Qt.vector4d((sessionMenu.x + sessRight) / 2, sessionMenu.y + sessionMenu.height / 2,
(sessRight - sessionMenu.x) / 2, sessionMenu.height / 2)
: Qt.vector4d(0, 0, 0, 0)
readonly property real launchBot: bar.height - Theme.frameWidth + 4
property vector4d launcher: launcherPanel.visible
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height)
@ -652,6 +461,22 @@ in
radius: 8
color: Theme.base01
// Sliding selection pill same tech as the power
// profile selector; glides between the buttons.
Rectangle {
width: 40
height: 40
radius: 8
color: Theme.base02
border.width: 1
border.color: Theme.base03
x: sessionCol.x
y: sessionCol.y + sessionMenu.selIdx * 44
Behavior on y {
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
}
}
Column {
id: sessionCol
anchors.centerIn: parent
@ -660,20 +485,13 @@ in
Repeater {
model: sessionMenu.actions
Rectangle {
Item {
id: sessBtn
required property var modelData
required property int index
readonly property bool selected: sessionMenu.selIdx === index
width: 40
height: 40
radius: 8
// Same selection grammar as the power-profile pill
// and calendar "today": base02 surface, base03 border
color: selected ? Theme.base02 : "transparent"
border.width: selected ? 1 : 0
border.color: Theme.base03
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
@ -703,6 +521,213 @@ in
}
}
// Launcher: rises out of the bottom frame edge (Super+R).
// Lives in the SDF field, so it melts into the frame and its
// height morphs live as results filter. Results sit above the
// search box; selection uses an animated ListView highlight.
Item {
id: launcherPanel
property bool open: false
readonly property real panelW: 420
property real targetH: 36 + launcherList.contentHeight
+ (launcherList.count > 0 ? 8 : 0) + 24
property real openH: open ? targetH : 0
Behavior on openH {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
x: Math.round((bar.width - panelW) / 2)
y: bar.height - Theme.frameWidth - openH
width: panelW
height: openH
visible: openH > 0.5
function toggle() {
open = !open;
if (open) {
searchInput.text = "";
launcherList.currentIndex = 0;
searchInput.forceActiveFocus();
}
}
function activate(item) {
if (!item) return;
item.execute();
open = false;
}
function score(name, extra, q) {
let n = name.toLowerCase();
if (n.startsWith(q)) return 5;
if (n.includes(" " + q)) return 4;
if (n.includes(q)) return 3;
if (extra && extra.toLowerCase().includes(q)) return 2;
// Fuzzy: q as an in-order subsequence of n (vktop
// vesktop); fewer skipped characters scores higher.
let qi = 0, gaps = 0, last = -1;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) {
if (last >= 0) gaps += i - last - 1;
last = i;
qi++;
}
}
if (qi === q.length) return 1 / (1 + gaps);
return 0;
}
property var entries: {
let q = searchInput.text.toLowerCase().trim();
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
if (q === "") {
apps.sort((a, b) => a.name.localeCompare(b.name));
return apps.slice(0, 8);
}
let scored = [];
for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
if (s > 0) scored.push({ app: apps[i], s: s });
}
scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name));
return scored.slice(0, 8).map(x => x.app);
}
// Content anchored to the bottom so the grow reveals upward
Item {
anchors.fill: parent
clip: true
opacity: launcherPanel.open ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
Column {
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: parent.horizontalCenter
width: launcherPanel.panelW - 24
spacing: 8
ListView {
id: launcherList
width: parent.width
height: contentHeight
interactive: false
model: launcherPanel.entries
highlight: Rectangle {
radius: 6
color: Theme.base02
}
highlightMoveDuration: 200
highlightMoveVelocity: -1
highlightResizeDuration: 0
delegate: Item {
required property var modelData
required property int index
width: launcherList.width
height: 32
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 10
Image {
visible: source != ""
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
source: Quickshell.iconPath(modelData.icon, true)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
elide: Text.ElideRight
width: 330
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: launcherList.currentIndex = index
onClicked: launcherPanel.activate(modelData)
}
}
}
Rectangle {
width: parent.width
height: 36
radius: 6
color: Theme.base01
Text {
id: searchIcon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "search"
color: Theme.base04
font.family: Theme.iconFont
font.pixelSize: 16
}
TextInput {
id: searchInput
anchors.left: searchIcon.right
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
clip: true
onTextChanged: launcherList.currentIndex = 0
Keys.onEscapePressed: launcherPanel.open = false
Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1)
Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1)
Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length)
Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
}
Text {
anchors.left: searchIcon.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === ""
text: "Search"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 13
}
}
}
}
}
// Click-outside dismissal for the keyboard-grabbing panels
HyprlandFocusGrab {
active: sessionMenu.open || launcherPanel.open
windows: [bar]
onCleared: {
sessionMenu.open = false;
launcherPanel.open = false;
}
}
property var activeDropdown: null
function closeAllDropdowns() {