Replaces the Persona x Mocha rice. NieR's menu language is kept whole — plain rectangles, hairline rules, letter-spaced upright capitals, a square marker on the current row, corner ticks round panels, and selection shown by inverting the row — but the colours come from ~/.wallpapers/space-1.jpg rather than from the game's beige: the grey-green void as the ground, the suit's sage grey as text, the planet's white as the one accent, and the hull's red indicator lights as the alarm. The Persona geometry is switched off rather than ripped out: Theme.skew and Theme.cut are 0, so P5Panel, Skew and Geom collapse to rectangles. Overshoot easing, italics and black weights are gone shell-wide. New, borrowed from caelestia's screen border: bar/Frame.qml draws the bar, the rail and a thin left/bottom band as one hull on the bottom layer, with the desktop as a rounded opening cut out of it. Frame also owns all four exclusive zones, so the bar and rail are placed from the screen edge. Hyprland moves to 1px borders lit from the planet white into grey, a soft wide shadow and gaps measured from the opening. Alacritty, fish and dunst get matching YoRHa palettes. Neovim, GTK/Qt and btop are still Mocha. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
550 lines
21 KiB
QML
550 lines
21 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 hands h/j/k/l over to navigation. That is the compromise the vim keys
|
|
// need: a launcher whose input eats `j` is a launcher you cannot use to find
|
|
// Jellyfin.
|
|
//
|
|
// Select mode is vim's normal mode, and the modes change the way vim changes
|
|
// them: Escape leaves insert, `i` (or `a`) goes back into it without leaving
|
|
// an `i` behind, and Escape from select mode — with no insert left to leave —
|
|
// ends the prompt. Unbound keys in select mode are eaten, not typed.
|
|
//
|
|
// 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
|
|
|
|
// Only the screen that had focus when the launcher was asked for shows it;
|
|
// the other copies stay dormant so the query is never typed into thin air.
|
|
readonly property bool open: Actions.launcherOpen
|
|
&& Actions.launcherScreen === modelData.name
|
|
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
|
|
? "J K MOVE L PIN H UNPIN i TYPE ESC QUIT"
|
|
: root.mode === "dmenu"
|
|
? "⏎ CHOOSE ESC SELECT ↑↓ MOVE"
|
|
: root.mode === "clip"
|
|
? "⏎ COPY ESC SELECT ↑↓ MOVE"
|
|
: root.pinned
|
|
? "⏎ OPEN ESC SELECT ↑↓ PICK"
|
|
: "ESC SELECT TAB PIN > SHELL = CALC : CLIP"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ keys
|
|
function handleKey(event: var): void {
|
|
if (!root.open) return;
|
|
|
|
const ctrl = (event.modifiers & Qt.ControlModifier) !== 0;
|
|
|
|
// Escape is the mode key, and it means what it means in vim: leave
|
|
// insert. From select mode there is nothing left to leave but the
|
|
// launcher itself, so the second press ends the prompt.
|
|
if (event.key === Qt.Key_Escape) {
|
|
if (!root.selectMode && root.results.length > 0) root.selectMode = true;
|
|
else Actions.closeLauncher();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
// …and Ctrl+C still walks out in one press, from wherever you are.
|
|
if (ctrl && event.key === Qt.Key_C) {
|
|
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.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.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 are commands, not text: `i` and `a` go back
|
|
// to insert without leaving an `i` behind, and everything unclaimed is
|
|
// swallowed rather than typed, so a stray `d` costs nothing at all.
|
|
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_I:
|
|
case Qt.Key_A:
|
|
root.selectMode = false;
|
|
input.forceActiveFocus();
|
|
// `a` appends, `i` leaves the cursor where it was — the same
|
|
// distinction, for the same reason.
|
|
if (event.key === Qt.Key_A) input.cursorPosition = input.text.length;
|
|
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;
|
|
input.forceActiveFocus();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
// Anything else in select mode is a command nobody bound. Eat it.
|
|
if (event.text !== "") event.accepted = true;
|
|
}
|
|
|
|
// ------------------------------------------------------------- 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.OutCubic
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|