diff --git a/settings/quickshell.nix b/settings/quickshell.nix index e1b80e9..ce6b419 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -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() {