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:
parent
d2bcdad2fe
commit
641d5c7e63
1 changed files with 246 additions and 221 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue