fuzzel could only answer "which app", so launching Brave at a URL meant picking the app, waiting for it, then typing the address somewhere else. The launcher now takes a verb and an object in one line: type `gitlab.enqs.de` and every app that can open it sorts to the top, default browser first, ready for Enter — or arrow/hjkl down to Brave's New Incognito Window, which is offered as a peer of Brave itself rather than hidden behind a submenu. services/Apps.qml is the brain and holds no UI: it flattens desktop entries and their actions into one index, classifies the typed argument (url / mail / path), ranks by fuzzy match plus handler affinity plus frecency, and launches. Launching through `uwsm app` keeps each app in its own scope; entries whose Exec declares no field code (Brave's `--incognito` action, among others) are launched by tokenising Exec per the Desktop Entry spec and appending the argument, never via `sh -c`. Four prefixes reuse the same window, so there is one picker and one set of keys: `>` runs a command, `=` evaluates arithmetic, `:` is clipboard history (cliphist, which had been a second fuzzel invocation), and a dmenu mode is driven over IPC. quickshell/scripts/takemi-dmenu is the bridge for that last one — a `fuzzel --dmenu` drop-in for scripts that want a picker, symlinked into ~/.local/bin. fluxo's bt menu now goes through it. Living in the Quickshell process means the window is on screen on the frame the bind fires, with no process to spawn. Removes fuzzel entirely: its config, its theme submodule, the package, and the last consumers (the bar sigil, fluxo's menu_command). Also drops hypr/scripts/powermenu.sh, which was fuzzel-based and bound to nothing.
527 lines
20 KiB
QML
527 lines
20 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Widgets
|
|
import "root:/config"
|
|
import "root:/components"
|
|
import "root:/popouts"
|
|
import "root:/services"
|
|
|
|
// The launcher. One input, one list, and a small amount of grammar.
|
|
//
|
|
// TYPING is always typing. Navigation lives on the arrows and on Ctrl+J/K so
|
|
// that nothing you can type is ever stolen from you — except in select mode,
|
|
// which you enter deliberately (the first arrow press does it) and which hands
|
|
// h/j/k/l over to navigation until you press Escape or Backspace. That is the
|
|
// compromise the vim keys need: a launcher whose input eats `j` is a launcher
|
|
// you cannot use to find Jellyfin.
|
|
//
|
|
// The other half of the grammar is the argument. Type a URL and the list turns
|
|
// into "open this with…", already sorted so the system default is row zero —
|
|
// so the fast path is type, Enter, done, and the slow path is one arrow key.
|
|
// Tab pins whatever is selected, which does the same thing for arguments that
|
|
// do not announce themselves as arguments.
|
|
PanelWindow {
|
|
id: root
|
|
|
|
required property var modelData
|
|
screen: modelData
|
|
|
|
readonly property bool open: Actions.launcherOpen
|
|
visible: root.open || closeAnim.running
|
|
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "takemi-launcher"
|
|
WlrLayershell.keyboardFocus: root.open
|
|
? WlrKeyboardFocus.Exclusive
|
|
: WlrKeyboardFocus.None
|
|
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
color: "transparent"
|
|
exclusionMode: ExclusionMode.Ignore
|
|
|
|
// ----------------------------------------------------------------- state
|
|
property string query: ""
|
|
property var pinned: null // an index item, frozen as the verb
|
|
property bool selectMode: false
|
|
property int selected: 0
|
|
|
|
readonly property int maxRows: 8
|
|
readonly property int rowHeight: 46
|
|
|
|
// --------------------------------------------------------------- grammar
|
|
readonly property string prefix: root.pinned ? "" : root.query.slice(0, 1)
|
|
// A dmenu request owns the window outright — there is no app search to
|
|
// fall back to, because the caller is waiting on a choice from its own
|
|
// list.
|
|
readonly property string mode: Apps.dmenuActive ? "dmenu"
|
|
: root.prefix === ">" ? "shell"
|
|
: root.prefix === "=" ? "calc"
|
|
: root.prefix === ":" ? "clip"
|
|
: "app"
|
|
|
|
readonly property bool prefixed: root.mode === "shell"
|
|
|| root.mode === "calc" || root.mode === "clip"
|
|
|
|
readonly property string body: root.prefixed
|
|
? root.query.slice(1)
|
|
: root.query
|
|
|
|
// With an app pinned, everything you type is the argument. Without one, an
|
|
// argument only exists if what you typed is self-evidently an object.
|
|
readonly property string argKind: root.pinned
|
|
? (Apps.classify(root.body) || "text")
|
|
: Apps.classify(root.body)
|
|
|
|
readonly property string arg: root.mode !== "app" || root.body.trim() === ""
|
|
? ""
|
|
: (root.pinned || root.argKind !== "")
|
|
? Apps.normalizeArg(root.body, root.argKind)
|
|
: ""
|
|
|
|
// What is left to match app names against. When the whole query has been
|
|
// claimed as an argument there is nothing left, and the list falls back to
|
|
// "every app, best handler first".
|
|
readonly property string term: root.mode !== "app" || root.pinned || root.arg !== ""
|
|
? ""
|
|
: root.body
|
|
|
|
readonly property var results: {
|
|
if (!root.open) return [];
|
|
|
|
if (root.mode === "calc") {
|
|
const v = Apps.calc(root.body);
|
|
if (v === "") return [];
|
|
return [{ kind: "calc", name: v, sub: "copy to clipboard",
|
|
icon: "accessories-calculator", value: v }];
|
|
}
|
|
|
|
if (root.mode === "dmenu") return Apps.searchDmenu(root.body);
|
|
if (root.mode === "clip") return Apps.searchClips(root.body);
|
|
|
|
if (root.mode === "shell") {
|
|
if (root.body.trim() === "") return [];
|
|
return [
|
|
{ kind: "shell", name: root.body, sub: "run", icon: "utilities-terminal" },
|
|
{ kind: "shellterm", name: root.body, sub: "run in a terminal",
|
|
icon: "utilities-terminal" }
|
|
];
|
|
}
|
|
|
|
// Pinned: the verb is decided, so the list narrows to that app and the
|
|
// things it can do — which is where "Brave, but a private window"
|
|
// lives. Same list, one step further in.
|
|
if (root.pinned) {
|
|
const id = root.pinned.id;
|
|
return Apps.index.filter(e => e.id === id)
|
|
.sort((a, b) => (a.kind === "app" ? 0 : 1) - (b.kind === "app" ? 0 : 1));
|
|
}
|
|
|
|
return Apps.search(root.term, root.arg, root.argKind);
|
|
}
|
|
|
|
onResultsChanged: root.selected = 0
|
|
|
|
// Typing the ":" prefix is the other way into the clipboard, and the
|
|
// history may have moved on since the window opened.
|
|
onModeChanged: if (root.mode === "clip") Apps.refreshClips()
|
|
onOpenChanged: {
|
|
if (root.open) {
|
|
PopupCoordinator.dismissAll();
|
|
root.reset();
|
|
openAnim.restart();
|
|
input.forceActiveFocus();
|
|
input.cursorPosition = input.text.length;
|
|
Apps.refreshClips();
|
|
} else {
|
|
// Dismissing a dmenu is a valid answer — an empty one. Without this
|
|
// the caller blocks until its own timeout.
|
|
Apps.finishDmenu("");
|
|
closeAnim.restart();
|
|
}
|
|
}
|
|
|
|
function reset(): void {
|
|
root.query = Actions.launcherSeed;
|
|
root.pinned = null;
|
|
root.selectMode = false;
|
|
root.selected = 0;
|
|
}
|
|
|
|
function move(delta: int): void {
|
|
const n = root.results.length;
|
|
if (n === 0) return;
|
|
root.selected = (root.selected + delta + n) % n;
|
|
list.positionViewAtIndex(root.selected, ListView.Contain);
|
|
}
|
|
|
|
function pin(): void {
|
|
const item = root.results[root.selected];
|
|
if (!item || (item.kind !== "app" && item.kind !== "action")) return;
|
|
root.pinned = item;
|
|
root.query = "";
|
|
root.selectMode = false;
|
|
input.forceActiveFocus();
|
|
}
|
|
|
|
function unpin(): void {
|
|
if (!root.pinned) return;
|
|
root.query = root.pinned.name.split(" · ")[0];
|
|
root.pinned = null;
|
|
input.forceActiveFocus();
|
|
}
|
|
|
|
function activate(): void {
|
|
const item = root.results[root.selected];
|
|
if (!item) return;
|
|
|
|
switch (item.kind) {
|
|
case "dmenu": Apps.finishDmenu(item.name); break;
|
|
case "clip": Apps.pasteClip(item); break;
|
|
case "calc": Apps.copy(item.value); break;
|
|
case "shell": Apps.runShell(root.body, false); break;
|
|
case "shellterm": Apps.runShell(root.body, true); break;
|
|
default: Apps.launch(item, root.arg); break;
|
|
}
|
|
Actions.closeLauncher();
|
|
}
|
|
|
|
// -------------------------------------------------------------- backdrop
|
|
Rectangle {
|
|
id: backdrop
|
|
anchors.fill: parent
|
|
color: Theme.alpha(Theme.base, 0.72)
|
|
opacity: 0
|
|
|
|
TapHandler { onTapped: Actions.closeLauncher() }
|
|
}
|
|
|
|
// ----------------------------------------------------------------- panel
|
|
Item {
|
|
id: shell
|
|
|
|
width: 680
|
|
height: header.height + divider.height + listBox.height + hint.height
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
// Sits above centre. A launcher pinned to the exact middle of a 1440p
|
|
// screen puts the list below the eyeline and the input above it; the
|
|
// input is the thing you are looking at.
|
|
y: parent.height * 0.22
|
|
|
|
opacity: 0
|
|
transform: Translate { id: rise; y: 24 }
|
|
|
|
PopoutSurface {
|
|
anchors.fill: parent
|
|
tone: root.arg !== "" ? Theme.accent : Theme.primary
|
|
padding: 0
|
|
slashWidth: root.arg !== "" ? 8 : 5
|
|
|
|
// ------------------------------------------------------- header
|
|
Item {
|
|
id: header
|
|
width: parent.width
|
|
height: 66
|
|
anchors.top: parent.top
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Theme.padL
|
|
anchors.rightMargin: Theme.padL
|
|
spacing: Theme.padM
|
|
|
|
Icon {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.mode === "shell" ? "" : root.mode === "calc" ? ""
|
|
: root.mode === "clip" ? ""
|
|
: root.mode === "dmenu" ? ""
|
|
: root.arg !== "" ? "" : ""
|
|
color: root.arg !== "" ? Theme.accent : Theme.primary
|
|
font.pixelSize: Theme.fsTitle
|
|
}
|
|
|
|
// The pin chip. Once this is on screen the input means
|
|
// "argument", and the chip is the only thing saying so.
|
|
Item {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.pinned !== null
|
|
width: visible ? chipText.width + Theme.padM * 2 : 0
|
|
height: 26
|
|
|
|
Skew {
|
|
anchors.fill: parent
|
|
color: Theme.accent
|
|
}
|
|
|
|
Text {
|
|
id: chipText
|
|
anchors.centerIn: parent
|
|
text: root.pinned ? root.pinned.name.toUpperCase() : ""
|
|
color: Theme.treatmentDestructiveText
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.weight: Font.Bold
|
|
font.letterSpacing: 2
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
TextInput {
|
|
id: input
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - x
|
|
text: root.query
|
|
onTextChanged: root.query = text
|
|
color: root.selectMode ? Theme.muted : Theme.text
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsLarge
|
|
selectionColor: Theme.accent
|
|
selectedTextColor: Theme.ink
|
|
clip: true
|
|
focus: true
|
|
renderType: Text.NativeRendering
|
|
|
|
// Ours must run before TextInput's, or select mode's
|
|
// h/j/k/l would be typed into the field before we ever
|
|
// saw them.
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: event => root.handleKey(event)
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: input.text === ""
|
|
text: root.pinned ? "argument…"
|
|
: root.mode === "shell" ? "command…"
|
|
: root.mode === "calc" ? "expression…"
|
|
: root.mode === "clip" ? "clipboard history…"
|
|
: root.mode === "dmenu"
|
|
? (Apps.dmenuPrompt || "choose…")
|
|
: "search, or paste a link"
|
|
color: Theme.muted
|
|
font: input.font
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count, right-aligned over the input's tail.
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Theme.padL
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.results.length > 0
|
|
? (root.selected + 1) + "/" + root.results.length
|
|
: ""
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.letterSpacing: 1
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
Skew {
|
|
id: divider
|
|
anchors.top: header.bottom
|
|
width: parent.width
|
|
height: Theme.stroke
|
|
color: Theme.alpha(Theme.outline, 0.8)
|
|
lean: 0
|
|
}
|
|
|
|
// -------------------------------------------------------- list
|
|
Item {
|
|
id: listBox
|
|
anchors.top: divider.bottom
|
|
width: parent.width
|
|
height: Math.min(root.results.length, root.maxRows) * root.rowHeight
|
|
+ (root.results.length > 0 ? Theme.padS * 2 : 0)
|
|
clip: true
|
|
|
|
Behavior on height {
|
|
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo }
|
|
}
|
|
|
|
ListView {
|
|
id: list
|
|
anchors.fill: parent
|
|
anchors.topMargin: Theme.padS
|
|
anchors.bottomMargin: Theme.padS
|
|
anchors.leftMargin: Theme.padM
|
|
anchors.rightMargin: Theme.padM
|
|
model: root.results
|
|
currentIndex: root.selected
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
reuseItems: true
|
|
|
|
delegate: ResultRow {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: list.width
|
|
entry: modelData
|
|
selected: index === root.selected
|
|
badge: root.arg !== "" ? "OPEN ⏎" : "⏎"
|
|
onActivated: {
|
|
root.selected = index;
|
|
root.activate();
|
|
}
|
|
onPinRequested: {
|
|
root.selected = index;
|
|
root.pin();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Empty state. Says which of the two ways to be empty it is,
|
|
// because "no results" and "nothing typed" want different
|
|
// things from you.
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: root.results.length === 0 && root.query !== ""
|
|
text: root.mode === "calc" ? "NOT AN EXPRESSION" : "NO MATCH"
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsSmall
|
|
font.letterSpacing: 4
|
|
renderType: Text.NativeRendering
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- hints
|
|
Item {
|
|
id: hint
|
|
anchors.top: listBox.bottom
|
|
width: parent.width
|
|
height: 30
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
color: Theme.muted
|
|
font.family: Theme.fontMono
|
|
font.pixelSize: Theme.fsMicro
|
|
font.letterSpacing: 3
|
|
renderType: Text.NativeRendering
|
|
text: root.selectMode
|
|
? "H J K L MOVE L PIN ESC TYPE"
|
|
: root.mode === "dmenu"
|
|
? "⏎ CHOOSE ESC CANCEL ↑↓ SELECT"
|
|
: root.mode === "clip"
|
|
? "⏎ COPY ESC BACK ↑↓ SELECT"
|
|
: root.pinned
|
|
? "⏎ OPEN ESC UNPIN ↑↓ PICK"
|
|
: "TAB PIN > SHELL = CALC : CLIPBOARD"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ keys
|
|
function handleKey(event: var): void {
|
|
if (!root.open) return;
|
|
|
|
const ctrl = (event.modifiers & Qt.ControlModifier) !== 0;
|
|
|
|
// Escape peels one layer at a time: select mode, then the pin, then
|
|
// the launcher. Nothing about it is ever a surprise.
|
|
if (event.key === Qt.Key_Escape) {
|
|
if (root.selectMode) root.selectMode = false;
|
|
else if (root.pinned) root.unpin();
|
|
else Actions.closeLauncher();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
|
root.activate();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Tab) {
|
|
root.pin();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Down || (ctrl && event.key === Qt.Key_J)
|
|
|| (ctrl && event.key === Qt.Key_N)) {
|
|
root.selectMode = true;
|
|
root.move(1);
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Up || (ctrl && event.key === Qt.Key_K)
|
|
|| (ctrl && event.key === Qt.Key_P)) {
|
|
root.selectMode = true;
|
|
root.move(-1);
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (ctrl && event.key === Qt.Key_U) {
|
|
root.query = "";
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (!root.selectMode) return;
|
|
|
|
// Select mode. The letters navigate; anything else drops you back into
|
|
// the input, having typed nothing, so a mistyped `d` costs one press
|
|
// rather than a mangled query.
|
|
switch (event.key) {
|
|
case Qt.Key_J: root.move(1); event.accepted = true; return;
|
|
case Qt.Key_K: root.move(-1); event.accepted = true; return;
|
|
case Qt.Key_L: root.pin(); event.accepted = true; return;
|
|
case Qt.Key_H: root.unpin(); event.accepted = true; return;
|
|
case Qt.Key_G:
|
|
root.selected = (event.modifiers & Qt.ShiftModifier)
|
|
? root.results.length - 1 : 0;
|
|
list.positionViewAtIndex(root.selected, ListView.Contain);
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Backspace:
|
|
root.selectMode = false;
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.text !== "") root.selectMode = false;
|
|
}
|
|
|
|
// ------------------------------------------------------------- animation
|
|
ParallelAnimation {
|
|
id: openAnim
|
|
NumberAnimation {
|
|
target: backdrop; property: "opacity"
|
|
to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad
|
|
}
|
|
NumberAnimation {
|
|
target: shell; property: "opacity"
|
|
to: 1; duration: Theme.durBase
|
|
}
|
|
NumberAnimation {
|
|
target: rise; property: "y"
|
|
from: 24; to: 0; duration: Theme.durSlow; easing.type: Easing.OutBack
|
|
}
|
|
}
|
|
|
|
ParallelAnimation {
|
|
id: closeAnim
|
|
NumberAnimation {
|
|
target: backdrop; property: "opacity"
|
|
to: 0; duration: Theme.durFast
|
|
}
|
|
NumberAnimation {
|
|
target: shell; property: "opacity"
|
|
to: 0; duration: Theme.durFast
|
|
}
|
|
NumberAnimation {
|
|
target: rise; property: "y"
|
|
to: 16; duration: Theme.durFast; easing.type: Easing.InQuad
|
|
}
|
|
}
|
|
}
|